ここまで、「UFOゲーム」を作ってきましたが、プログラミングの勉強としてはよくても、ゲームとしてはさすがに遊べたものではないと、今どきの目の肥えた人でなくても思うのではないでしょうか。
そこで今回は、こちらにあるシューティングゲームを作ることにします。
こんなゲームを作ります。
UFOゲームでは、敵といえばUFOだけで、しかもそのUFOも1機だけでした。ここで作るシューティングゲームでは、敵の爆弾を含めれば4種類の敵が登場します。そして、UFO以外の敵は、同時に複数登場します。
突入前の、左右に動いている間は、、一番左の画像(alien_normal.png)を使います。突っ込んでくるときに、キャノン砲がエイリアンよりも右にいれば、その続きの4枚の画像、alien_r1.png~alien_r4.pngを使って時計回りにくるりと回転します。キャノン砲がエイリアンよりも左にいれば、さらに次の画像alien_l4.pngを使って左向きになるまで回転します。
このように、固定の6枚の画像を切り替えて使うので、エイリアンがキャノン砲に向けて突っ込んでくるとき、正確にキャノン砲の方を向いているわけではありません。そのためちょっと違和感を感じるときもありますが、まあ、よしとしましょう※1。
エイリアンは、キャノン砲のビーム1発で破壊できます。
UFOゲームでは、UFOにビームが当たると、UFOはしばらくその場で爆発炎上していましたが、今回のシューティングゲームにはたくさんの敵が出てきますのでいちいち長々と爆発しているわけにもいきません。そこで、以下の3枚の画像を連続表示することで爆発を表現しています。
また、UFOゲームでは、UFOが爆発炎上している間、画面はUFOの爆発炎上以外完全に止まってしまい、キャノン砲を動かすこともできませんでした。そうなったのは、ここで説明したように、UFOの爆発をmainLoop()関数とはまったく別のshootDown()関数で行っているためです。
敵がUFO1機ならこれでもよいでしょうが、今回はたくさんの敵が動いているのでたとえ3コマであっても止めるわけにはいきません。よって、この3コマの爆発の描画は、mainLoop()の中で行う必要があります。
今回、キャノン砲がやられた時には、こんな感じで爆発します(ペイントで描きました。がんばった)。
敵の爆発だけでなく、キャノン砲がやられた時も、この爆発の間すべての敵が停止してしまったのでは変でしょう。よってキャノン砲の爆発の描画もmainLoop()の中で行う必要があります。
キャノン砲の爆発が一通り終わってしばらくしたら、すべての敵がいったん消えて、また最初からゲームが始まります。
前回、配列を使ってビームを3発まで連射可能にしましたが、今回は敵が多いので、10発まで連射可能にしました。
このシューティングゲームは、UFOゲームよりはずいぶん複雑に見えるでしょう。なにしろ敵は(たった4種類とはいえ)複数の種類がありますし、それぞれが複数登場します。
ただ、前回配列とオブジェクトを扱いましたので、もう皆さんはこのゲームを作ることは一応は可能です。敵は複数登場しますので配列に保持すればよいですし、敵1機あたりの情報はオブジェクトに保持すればよいでしょう。
たとえば、UFOの場合、各UFOごとに※2以下の情報を持つことになります。
エイリアンはもうちょっとややこしくて、以下の情報が必要です。
落下中の場合の、移動方向のX軸、Y軸方向のスピード、というのは、下図の通り、エイリアンが斜めに突っ込んでくるときのX軸方向とY軸方向の1回の移動あたりの移動量を指しています。くるっと回った後で、キャノン砲の位置を元にこのふたつの移動量を算出し、以後その値をX座標、Y座標に加えながら移動します。
フライングボードは以下。
敵の爆弾は以下のふたつだけです。
さて、これらの情報を保持するオブジェクトをどのように保持すればよいでしょうか。
以下のように、4つの配列で持てばよいのでは、と思った人もいるかもしれません。
let aliens = []; // エイリアンの配列 let flyingBoards = []; // フライングボードの配列 let ufos = []; // UFOの配列 let enemyBombs = []; // 敵の爆弾の配列
しかし、JavaScriptの配列はどんな型も要素に持てるので、以下のように1本の配列にしてしまう方が扱いは楽です。enemiesというのは、enemy(敵)を、(配列なので)複数形にしたものです。
let enemies = []; // 敵の配列
このゲームの全体的な構造は、以下のようになります。
mainLoop() { ①一定の条件で、敵を出現させる ②描画する ②-1 敵を描画する ②-2 キャノン砲を描画する ②-3 ビームを描画する ②-4 敵の爆発を描画する ③衝突判定する ③-1 ビームと敵の衝突判定 ③-2 敵とキャノン砲の衝突判定 ④動かす ④-1 敵を動かす ④-2 キャノン砲を動かす ④-3 ビームを動かす ④-4 敵を動かす ④-5 敵の爆発の画像をひとコマ進める setTimeout(mainLoop, 20); }
描画して、衝突判定して、動かす、という基本的な流れは、UFOゲームと変わりません。
問題は、敵の種類がいろいろあるのに、どうやってごちゃごちゃにならないようにプログラムを書くか、ということです。何しろmainLoop()関数は、UFOゲームでさえ結構な長さです。上の、「②-1 敵を描画する」とか「④-1 敵を動かす」のあたりの処理をmainLoop()関数の中にべたべたと書いていったら、ちょっと読めないくらい長い関数になってしまいそうです。
これを防ぐ考え方として、敵の描画とか移動とかを、それぞれ関数に切り出す、という方法があります。
まず、上で、敵をenemiesというひとつの配列に格納することにしました。これだけだと、配列の各要素がエイリアンだかフライングボードだかUFOだか区別がつきませんから、各オブジェクトに「種別」を持たせることにします。
「種別」のプロパティ名をtypeとしたとして、たとえばそれが0ならエイリアン、1ならフライングボード、2ならUFO、3なら爆弾、というように決めます。
そうすれば、敵の移動の処理は、以下のように書くことができます。
for (let i = 0; i < enemies.length; i++) { // 各オブジェクトの種別(type)に応じて、それぞれの移動関数を呼び出す if (enemies[i].type === 0) { // エイリアン moveAlien(enemies[i]); } else if (enemies[i].type === 1) { // フライングボード moveFlyingBoard(enemies[i]); } else if (enemies[i].type === 2) { // UFO moveUfo(enemies[i]); } else if (enemies[i].type === 3) { // 爆弾 moveEnemyBomb(enemies[i]); } }
これなら、たとえばエイリアンの移動処理はmoveAlien()関数に分離できますから、mainLoop()関数をそれほどぐちゃぐちゃにしなくてすむでしょう。
この方法は、これはこれで立派に機能します。
しかし、今のJavaScriptでは、クラス(class)という機能を使うことで、これをもっと直接的にわかりやすく書くことができます。
上で、敵の「種別」を表現するのに、「0ならエイリアン、1ならフライングボード、2ならUFO、3なら爆弾」という取り決めをしました。
こういう「取り決め」は、覚えていられるうちはよいのですが、人間忘れてしまうものですし、なにより他人が見たときわけがわかりません。そこで、いまどきのたいていのプログラミング言語では、列挙型(enumerated type)という機能が用意されています。列挙型というのは、ここでいう「敵の種別」のような、いくつかの決まった値をとるデータ型です。たとえばJavaなら、enumキーワードを使って以下のように書けば、「敵の種別」を表現するデータ型EnemyTypeを作り出すことができます。
enum EnemyType { ALIEN, FLYING_BOARD, UFO, ENEMY_BOMB }
これを使う時には、「EnemyType.ALIEN」のように書きます。
if (enemies[i].type === EnemyType.ALIEN) { (後略)
ところが、JavaScriptには列挙型の機能がありません。まあ、列挙型のメリットの多くは、型を陽に指定することでプログラムがわかりやすくなったり間違えたときにコンパイルエラーになったりすることなので(たとえば関数の引数が列挙型なら、使う側は、何を渡せばよいのか一目でわかる)、変数に型のないJavaScriptでは意味が薄いというのも事実です。
とはいえ「0だったらエイリアン」じゃあんまりということで、対案を考えます。ひとつは、以下のようにconstで定数を定義することです。
const ENEMY_ALIEN = 0; const ENEMY_FLYING_BOARD = 1; const ENEMY_UFO = 2; const ENEMY_ENEMY_BOMB = 3;
うっかり他の定数と名前が衝突しないように、前に「ENEMY_」とを付けました。
こうしておけば、これを使う時には、
if (enemies[i].type === ENEMY_ALIEN) { (後略)
のように書けるので、「enemies[i].type === 0」よりはわかりやすいでしょう。
他の方法としては、オブジェクトのプロパティにするという方法もあります。
const EnemyType = { ALIEN: 0, FLYING_BOARD: 1, UFO: 2, ENEMY_BOMB : 3 };
この方法だと、使う時は、
if (enemies[i].type === EnemyType.ALIEN) { (後略)
と書くことになります。
プロパティを使う場合、「EnemyType.ALIEN = 10;」のようにうっかり代入してしまうことがあるかもしれませんが「Object.freeze(EnemyType);」と書くことで、オブジェクトをfreeze(フリーズ/凍結)することができます。オブジェクトをフリーズしたら、もうそのオブジェクトは、プロパティの値を変えることもプロパティを追加することもできません。
プロパティを使う方法の方が、まとめて定義している感があってよいように見えますが、これはこれで欠点もあって、プロパティ名をミスタイプしても何のエラーにもならないんですよねえ(JavaScriptでは、存在しないプロパティにアクセスすると、undefinedが返るだけでエラーにはならない)。困ったものです。
「はじめに」で書きましたが、JavaScriptは「プロトタイプベースのオブジェクト指向言語」です。それに対し、他のたいていの言語(JavaとかC#とかRubyとかPythonとか)は「クラスベースのオブジェクト指向言語」です。よって、もともとJavaScriptには「クラス」という概念はありませんでした。
しかし、それではやっぱり不便ということで、ES2015からクラス(class)が導入されました。
クラスというのは、オブジェクトを作るためのひな型というか、テンプレートのようなものです。「オブジェクト」と言えば、前回ビームのX座標、Y座標を保持するオブジェクトを作りました。このページの上の方でも、エイリアンやフライングボードの情報を保持するにはどんなオブジェクトにすればよいか、という話をしています。ここまで説明したオブジェクトは、「(X座標、Y座標のような)複数のデータをまとめるためのもの」でした。
ここで、クラスから作り出す「オブジェクト」も、実のところ同じものです。ただし、今回作るオブジェクトは、X座標、Y座標のようなデータだけではなくて、そのオブジェクトを操作する関数――メソッド(method)も備えています。
このゲームのエイリアンを表現するAlienクラスについて、本物はちょっとサンプルにするには長すぎるので、まずは小さなサンプルを作ってみます。
1: class Alien { 2: // コンストラクタ関数。newでオブジェクトが作られたときに呼び出される。 3: constructor(x, y) { 4: this.x = x; 5: this.y = y; 6: } 7: 8: // エイリアンを移動させるメソッド 9: move() { 10: this.x += 5; // 5ピクセル右に動かす 11: } 12: 13: // エイリアンを描画するメソッド。 14: // ここでは、画面に描かないで、コンソールに座標だけ表示する。 15: draw() { 16: console.log("エイリアンの座標は(" + this.x + ", " + this.y + ")"); 17: } 18: } 19: 20: // エイリアンのオブジェクト(インスタンス)を作成する。 21: const alien = new Alien(0, 20); 22: 23: // エイリアンを10回移動させる。 24: for (let i = 0; i < 10; i++) { 25: alien.draw(); 26: alien.move(); 27: }
1行目から18行目までが、エイリアンを表現するAlienクラスの定義です。
3~6行目が、コンストラクタ(constructor)という特別なメソッドの定義です。コンストラクタは、newでオブジェクトを作るときに呼び出されるメソッドです。このAlienのコンストラクタは引数としてエイリアンの初期座標を受け取るようになっているので、「new Alien(10, 20)」のようにしてエイリアンのオブジェクトを作り出すと、コンストラクタが呼び出されます。JavaScriptの場合、コンストラクタのメソッド名は「constructor」でなければなりません。
コンストラクタの中では、渡されたX, Y座標を、this.xとthis.yに設定しています。このthis(ディス)は、その名の通り「この※3」エイリアンを指すオブジェクトで、ここでは「このエイリアンのX座標とY座標」に、引数で渡されたx, yを設定している、ということになります。
9~11行目は、エイリアンを移動させるメソッドmove()です。ここでは、this.x(このエイリアンのX座標)に5を足しているので、もしちゃんと画面に表示しているならエイリアンは右に少し動く、ということになるでしょう。
15~17行目は、エイリアンを描画するメソッドdraw()です。本来はゲームの画面に描画するところですが、ここでは簡単にするためコンソールに座標だけ表示しています。
20行目では、newを使ってエイリアンのオブジェクトを生成しています。クラスをもとにオブジェクトを生成する際には、ここにあるように「new クラス名(コンストラクタの引数)」という書き方をします。クラスをもとに作られたオブジェクトは、インスタンス(instance)と呼ぶこともあります。
24~27行目では、forループでエイリアンのdraw()メソッドとmove()メソッドを10回呼び出しています。これにより、コンソールには、5ずつ右に移動したエイリアンの座標が表示されることになります(図4)。
画面上でエイリアンが右に動くならともかく、コンソールに座標だけ出しても面白みに欠けるかもしれませんが、理屈は理解できるのではないでしょうか。
もちろん、「new Alien(初期座標)」を何度も呼べば、エイリアンのインスタンスはたくさん作ることができますし、それぞれ別のX座標、Y座標を保持します。
上のように、クラスを作ると、決まったメソッドを持ったオブジェクトをnewで簡単に作ることができます。
そして、今回作ろうとしているシューティングゲームでは、爆弾を含めて4種類の敵が登場します。ということは、敵の分で4個のクラスを定義することになりますが、その際、同じ意味を持つメソッドは名前を統一しておけば、たとえば「enemy.move();」のように書くだけで、enemyがAlienならAlienのmove()メソッドが、UfoならUfoのmove()メソッドが呼び出されます。
上の方で、各オブジェクトに敵の種類を持たせたうえで、敵の移動の処理を以下のように書くことができる、と書きました(リストを再掲)。
for (let i = 0; i < enemies.length; i++) { // 各オブジェクトの種別(type)に応じて、それぞれの移動関数を呼び出す if (enemies[i].type === 0) { // エイリアン moveAlien(enemies[i]); } else if (enemies[i].type === 1) { // フライングボード moveFlyingBoard(enemies[i]); } else if (enemies[i].type === 2) { // UFO moveUfo(enemies[i]); } else if (enemies[i].type === 3) { // 爆弾 moveEnemyBomb(enemies[i]); } }
しかし、敵ごとにmove()メソッドを付ける、という方針にすれば、上と同じことを以下のようにはるかにすっきりと書くことができます。
for (let i = 0; i < enemies.length; i++) { enemies[i].move(); }
この、同じメソッド呼び出しでも呼び出す先のクラスの種類によって違う動きをする、という挙動を、ポリモルフィズム(polymorphism)または多態と呼びます。
ここまでで、クラスの書き方と使い方について説明しましたので、いよいよここからシューティングゲームを作っていきます。
UFOゲームを作るとき、このページのリスト5で、UFOを表示しようとして失敗しています。UFOの画像を読み込んですぐに描画すると、描画の時点で画像のロードが終わっていないので表示されません。ただ、UFOゲームの時は、UFOは何度も表示されるのでそのうちロードが終わってばれないから問題なかろう、ということでそのままにしました。
今回も問題は起きないかもしれませんが、この際なのでこれについてもちゃんと対応しておきます。
// 画像のロードがどこまで終わったかを保持するオブジェクト。 // loadImage()関数を参照のこと。 const loadingStatus = {}; (中略) // 画像のロード const ufoImage = loadImage("./ufo.png"); const cannonImage = loadImage("./cannon.png"); const cannonExplosionImage = new Array(8); cannonExplosionImage[0] = loadImage("./cannon_explosion_01.png"); cannonExplosionImage[1] = loadImage("./cannon_explosion_02.png"); cannonExplosionImage[2] = loadImage("./cannon_explosion_03.png"); cannonExplosionImage[3] = loadImage("./cannon_explosion_04.png"); cannonExplosionImage[4] = loadImage("./cannon_explosion_05.png"); cannonExplosionImage[5] = loadImage("./cannon_explosion_06.png"); cannonExplosionImage[6] = loadImage("./cannon_explosion_07.png"); cannonExplosionImage[7] = loadImage("./cannon_explosion_08.png"); (中略) function loadImage(fileName) { const image = new Image(); loadingStatus[fileName] = false; image.src = fileName; let loadedAll = true; image.onload = function() { const keys = Object.keys(loadingStatus); loadingStatus[fileName] = true; for (let i = 0; i < keys.length; i++) { if (!loadingStatus[keys[i]]) { loadedAll = false; } } if (loadedAll) { initialize(); } } return image; }
今回は、画像をちゃんと全部読み込んだかを判定するため、loadingStatusというオブジェクトを用意しました。これは、画像のファイル名をキーとして、値としては論理型を持ちます。画像が読み込まれるまではfalse、読み込まれたらtrueになります。
そのうえで、UFOの画像やらキャノン砲の画像やら、キャノン砲が爆発するパターンの画像やらをloadImage()関数で読み込みます(キャノン砲の爆発パターンは、8枚もあるので配列で保持しています)。loadImage()関数の方では、loadingStatus(ファイル名)をfalseに設定したうえで、ロードが終わったら、onload()の中でtrueにし、かつ、その時点でloadingStatusのすべての要素がtrueなら、initialize()関数を呼び出しています(initialize(イニシャライズ)とは「初期化」という意味です)。このinitialize()関数の中で各種初期化を行い、最後にmainLoop()関数を呼び出すことでゲームが開始されます。こうすることで、ゲームが始まる時点ではすべての画像のロードが終わっていることが保証できるわけです。
「あれ? loadImage()関数側では最終的に画像が何枚になるかわからないのだから、この方法では全部読み込んだことは判定できないのでは? たとえば画像が全部で10枚必要だとしても、loadImage()が3回呼ばれた時点で3枚の画像の読み込みが終われば、全部の画像を読み込んだと判断されて、initialize()が呼ばれてしまうのでは?」と思った人もいるかもしれません。
この心配は、このプログラムが画像読み込みのためにloadImage()を何度も呼び出している最中に、onload()が呼ばれると思うところから出てくるのだと思います。しかし、実はJavaScriptというのはシングルスレッド(single thread)の言語であり、処理の実行中に別の処理が動くことはありません(そういうことができる言語はマルチスレッド(multi thread)と呼びます)。よって、onloadやonkeydownとかsetTimeout()で設定した関数は、いったん処理を終えてブラウザに制御が戻るまで呼び出されることはありません。よって、loadImage()を全部呼び終わるまではonload()は呼ばれませんから大丈夫です。
上の方で考えたAlienクラスのサンプルでは、エイリアンには移動のメソッドmove()と描画のメソッドdraw()を用意しました。
単に敵が画面上をひょこひょこ動くだけならこれでよいかもしれませんが、実際のシューティングゲームでは、ビームに当たって破壊されたりする必要もあります。エイリアンに限らず「敵」全般に、どのようなメソッドが必要なのかを挙げていきます。
敵の初期位置とか、最初の状態(エイリアンなら、右移動中か左移動中か旋回中か落下中か)を設定する必要があるので、敵にはそれぞれコンストラクタが必要です。
リスト1のAlienクラスのサンプルでは、コンストラクタで初期位置のX座標、Y座標を受け取るようにしましたが、実際には敵の初期位置は敵ごとに違うので、newを呼ぶ側から渡すより、コンストラクタの中で決めた方がよさそうです(サンプルでは、コンストラクタに引数を渡せることを示したかったのでそうしました)。よって、実際のシューティングゲームの敵クラスのコンストラクタには、引数はありません。
敵を移動させる際に呼び出すメソッドです。mainLoop()を1回実行するごとに、mainLoop()の中から1回ずつ呼び出されるので、「敵」の側では、move()の呼び出しごとに、敵を1ターン分ずつ動かすことになります。
move()メソッドに引数はありませんが、戻り値はあります。通常はAfterMove.NORMALを返しますが、移動により、その敵が画面外に出てしまう時は、AfterMove.DELETEを返します。呼び出し側では、AfterMove.DELETEを返された時は、敵を管理している配列enemiesから、その敵を削除します。
なお、このAfterMoveというのは、上の補足で紹介した方法で作成した列挙型です。英語のafterは「~の後」という意味ですので、「動いた後」にどうするかを示す型です。
// 移動後の挙動を示す列挙型 const AfterMove = { NORMAL : 0, // 通常 DELETE : 1 // enemies配列から削除する };
このシューティングゲームの場合、たいていの敵は、画面の下の方に消えていきます。エイリアンは最後はキャノン砲に突っ込んできて画面の下に消えますし、フライングボードや敵の爆弾は下に落ちていきます。よって、たいていは「画面の最下端より下に行ったら」その敵を消せばよいのですが、UFOだけは一定時間飛び回ったら自分から上の方に消えていくので、その判定方法は使えません。よって、現状、AfterMove.DELETEを返すのはUfoのmove()だけです。
その敵を描画する際に呼び出すメソッドです。画像なりを描画するにはcontextが必要なので、contextは引数で渡すようにしました。戻り値はありません。
なお、UFOゲームの時は、UFOの座標を示す変数ufoX、ufoYは、UFOの左上の座標を保持していました。drawImage()に渡すのは左上の座標だからです。
しかし、今回は、エイリアンはクルッと回るしフライングボードも回転により画像の高さが変わります。そこで、それぞれの敵で保持するX座標、Y座標は、「画像の中心の位置」としました。このゲームの場合、その方が簡単そうです。
ビームとの衝突判定を行うメソッドがhitTest()メソッドです。ビームの上端の座標を引数として渡します(UFOゲーム同様、ビームの衝突判定はビームの上端にしかありません)。
衝突判定の結果を、HitTestResult列挙型で返します。
// ビームの当たり判定を返す列挙型 const HitTestResult = { NOT_HIT : 0, // 当たらなかった HIT : 1, // 当たった HIT_AND_DESTROY : 2 // 当たり、破壊された };
「当たったかどうか」を返すだけなら、HitTestResultの値は2種類だけでよさそうですが、このゲームのフライングボードは、5発ビームが当たるまでは破壊されません。そこで、フライングボードのhitTest()メソッドは、4回目まではHitTestResult.HITを返し、5回目当たって初めてHitTestResult.HIT_AND_DESTROYを返します。呼び出し側では、HitTestResult.HIT_AND_DESTROYが返されたら、その場所に「爆発」のオブジェクトを置いた上で、その敵をenemies配列から削除します。HitTestResult.HITの場合は、敵は破壊しませんが、ビームはその場で消さなければいけません。
getHitBox()は、「その敵の衝突判定のある長方形」を返すメソッドです。このメソッドは、敵とキャノン砲の衝突判定に使います。エイリアンやフライングボードや敵の爆弾がキャノン砲にぶつかると、キャノン砲が負けて爆発しますが、その判定に使います。
getHitBox()は、HitBoxクラスのインスタンスを返します。HitBoxクラスの定義は以下の通り。
class HitBox { constructor(xMin, yMin, xMax, yMax) { this.xMin = xMin; this.yMin = yMin; this.xMax = xMax; this.yMax = yMax; } }
JavaScriptのメソッドは、ひとつの戻り値しか返せませんので、このようにクラスのインスタンスを返すことでxMin、yMin、xMax、yMaxの4つの値を返しています。minはminimum(最小)、maxはmaximum(最大)の略ですので、それぞれX座標、Y座標の最小値、最大値を表しています(図5)。
このあたりで、シューティングゲームの全リストを掲載します。さすがにUFOゲームと比べたら結構な長さですね。
1: <!DOCTYPE html> 2: <html lang="ja"> 3: <head> 4: <meta charset="UTF-8"> 5: <title>シューティングゲーム</title> 6: </head> 7: <body> 8: <h1>シューティングゲーム</h1> 9: <canvas id="canvas" width="800px" height="600px" style="background-color:black;"></canvas> 10: <script> 11: "use strict"; 12: 13: // 画像のロードがどこまで終わったかを保持するオブジェクト。 14: // loadImage()関数を参照のこと。 15: const loadingStatus = {}; 16: 17: // 画像のロード 18: const ufoImage = loadImage("./ufo.png"); 19: const cannonImage = loadImage("./cannon.png"); 20: const cannonExplosionImage = new Array(8); 21: cannonExplosionImage[0] = loadImage("./cannon_explosion_01.png"); 22: cannonExplosionImage[1] = loadImage("./cannon_explosion_02.png"); 23: cannonExplosionImage[2] = loadImage("./cannon_explosion_03.png"); 24: cannonExplosionImage[3] = loadImage("./cannon_explosion_04.png"); 25: cannonExplosionImage[4] = loadImage("./cannon_explosion_05.png"); 26: cannonExplosionImage[5] = loadImage("./cannon_explosion_06.png"); 27: cannonExplosionImage[6] = loadImage("./cannon_explosion_07.png"); 28: cannonExplosionImage[7] = loadImage("./cannon_explosion_08.png"); 29: const alienImageNormal = loadImage("./alien_normal.png"); 30: const alienImageR1 = loadImage("./alien_r1.png"); 31: const alienImageR2 = loadImage("./alien_r2.png"); 32: const alienImageR3 = loadImage("./alien_r3.png"); 33: const alienImageR4 = loadImage("./alien_r4.png"); 34: const alienImageL4 = loadImage("./alien_l4.png"); 35: const flyingBoardImage = new Array(4); 36: flyingBoardImage[0] = loadImage("./flyingboard_0.png"); 37: flyingBoardImage[1] = loadImage("./flyingboard_1.png"); 38: flyingBoardImage[2] = loadImage("./flyingboard_2.png"); 39: flyingBoardImage[3] = loadImage("./flyingboard_3.png"); 40: const explosionImage = new Array(3); 41: explosionImage[0] = loadImage("./explosion_0.png"); 42: explosionImage[1] = loadImage("./explosion_1.png"); 43: explosionImage[2] = loadImage("./explosion_2.png"); 44: 45: const canvas = document.getElementById("canvas"); 46: const context = canvas.getContext("2d"); 47: 48: const UFO_SPEED = 10; 49: const UFO_EXIT_SPEED = 5; 50: const CANNON_SPEED = 10; 51: const BEAM_LENGTH = 20; 52: const BEAM_SPEED = 10; 53: const BOTTOM_MARGIN = 5; 54: 55: // ビームの当たり判定を返す列挙型 56: const HitTestResult = { 57: NOT_HIT : 0, // 当たらなかった 58: HIT : 1, // 当たった 59: HIT_AND_DESTROY : 2 // 当たり、破壊された 60: }; 61: Object.freeze(HitTestResult); 62: 63: // 移動後の挙動を示す列挙型 64: const AfterMove = { 65: NORMAL : 0, // 通常 66: DELETE : 1 // enemies配列から削除する 67: }; 68: Object.freeze(AfterMove); 69: 70: let ufoFlag; 71: let cannonX; 72: let leftKeyPressed = false; 73: let rightKeyPressed = false; 74: let spaceKeyReleased = true; 75: let gameOverFlag; 76: let gameOverCounter; 77: let cannonExplosionCounter; 78: 79: let beams = []; 80: let enemies = []; 81: let explosions = []; 82: 83: document.onkeydown = keyDown; 84: document.onkeyup = keyUp; 85: 86: class Beam { 87: constructor(x, y) { 88: this.x = x; 89: this.y = y; 90: } 91: 92: draw(context) { 93: context.strokeStyle = "yellow"; 94: context.beginPath(); 95: context.moveTo(this.x, this.y); 96: context.lineTo(this.x, this.y + 20); 97: context.stroke(); 98: } 99: 100: move() { 101: this.y -= 10; 102: } 103: } 104: 105: // エイリアンの状態を示す列挙型 106: const AlienMode = { 107: TO_RIGHT : 0, // 右に移動中 108: TO_LEFT : 1, // 左に移動中 109: TURNING : 2, // 旋回中 110: FALLING : 3 // 落下中 111: }; 112: Object.freeze(AlienMode); 113: 114: class Alien { 115: constructor() { 116: this.currentImage = alienImageNormal; 117: this.x = 0; 118: this.y = Math.random() * 300 + (this.currentImage.height / 2); 119: this.mode = AlienMode.TO_RIGHT; 120: } 121: 122: draw(context) { 123: context.drawImage(this.currentImage, 124: this.x - (this.currentImage.width / 2), 125: this.y - (this.currentImage.height / 2)); 126: } 127: 128: move() { 129: if (this.mode === AlienMode.TO_RIGHT || this.mode === AlienMode.TO_LEFT) { 130: if (Math.random() < 0.005) { 131: this.mode = AlienMode.TURNING; 132: this.counter = 1; 133: } else { 134: if (this.mode === AlienMode.TO_RIGHT) { 135: if (this.x < canvas.width - this.currentImage.width / 2) { 136: this.x += 5; 137: } else { 138: this.x -= 5; 139: this.mode = AlienMode.TO_LEFT; 140: } 141: } else { 142: if (this.x > this.currentImage.width / 2) { 143: this.x -= 5; 144: } else { 145: this.x += 5; 146: this.mode = AlienMode.TO_RIGHT; 147: } 148: } 149: } 150: } else if (this.mode === AlienMode.TURNING) { 151: if (this.counter === 1) { 152: this.currentImage = alienImageR1; 153: this.x += 5; 154: } else if (this.counter === 2) { 155: this.currentImage = alienImageR2; 156: this.x += 5; 157: this.y += 5; 158: } else if (this.counter === 3) { 159: this.currentImage = alienImageR3; 160: this.x += 5; 161: this.y += 8; 162: } else if (this.counter === 4) { 163: this.currentImage = alienImageR4; 164: this.x += 5; 165: this.y += 10; 166: } else if (this.counter === 5) { 167: this.mode = AlienMode.FALLING; 168: const hypotenuse = Math.sqrt((cannonX - this.x) * (cannonX - this.x) 169: + (canvas.height - this.y) * (canvas.height - this.y)); 170: this.xSpeed = (cannonX - this.x) / hypotenuse * 10; 171: this.ySpeed = (canvas.height - this.y) / hypotenuse * 10; 172: if (this.xSpeed < 0) { 173: this.currentImage = alienImageL4; 174: } 175: } 176: this.counter++; 177: } else if (this.mode === AlienMode.FALLING) { 178: this.x += this.xSpeed; 179: this.y += this.ySpeed; 180: } 181: 182: if (this.mode !== AlienMode.TURNING) { 183: if (Math.random() < 0.01) { 184: enemies.push(new EnemyBomb(this.x, this.y)); 185: } 186: } 187: return AfterMove.NORMAL; 188: } 189: 190: hitTest(x, y) { 191: const hitBox = this.getHitBox(); 192: if (x > hitBox.xMin && x < hitBox.xMax 193: && y > hitBox.yMin && y < hitBox.yMax) { 194: return HitTestResult.HIT_AND_DESTROY; 195: } 196: } 197: 198: getHitBox() { 199: return new HitBox(this.x - (this.currentImage.width / 2), 200: this.y - (this.currentImage.height / 2), 201: this.x + (this.currentImage.width / 2), 202: this.y + (this.currentImage.height / 2)); 203: } 204: } 205: 206: class FlyingBoard { 207: constructor() { 208: this.x = Math.random() * canvas.width; 209: this.y = 0; 210: this.counter = 0; 211: this.hitCounter = 0; 212: this.currentImage = flyingBoardImage[0]; 213: } 214: 215: draw(context) { 216: context.drawImage(this.currentImage, 217: this.x - (this.currentImage.width / 2), 218: this.y - (this.currentImage.height / 2)); 219: } 220: 221: move() { 222: this.y += 5; 223: this.counter++; 224: this.currentImage = flyingBoardImage[Math.floor(this.counter / 3) % 4]; 225: return AfterMove.NORMAL; 226: } 227: 228: hitTest(x, y) { 229: const hitBox = this.getHitBox(); 230: if (x > hitBox.xMin && x < hitBox.xMax 231: && y > hitBox.yMin && y < hitBox.yMax) { 232: this.hitCounter++; 233: if (this.hitCounter === 5) { 234: return HitTestResult.HIT_AND_DESTROY; 235: } else { 236: return HitTestResult.HIT; 237: } 238: } 239: } 240: 241: getHitBox() { 242: return new HitBox(this.x - (this.currentImage.width / 2), 243: this.y - (this.currentImage.height / 2), 244: this.x + (this.currentImage.width / 2), 245: this.y + (this.currentImage.height / 2)); 246: } 247: } 248: 249: class Ufo { 250: constructor() { 251: this.x = 0; 252: this.y = 0; 253: this.targetX = 0; 254: this.targetY = 0; 255: this.counter = 0; 256: ufoFlag = true; 257: } 258: 259: draw(context) { 260: context.drawImage(ufoImage, 261: this.x - (ufoImage.width / 2), 262: this.y - (ufoImage.height / 2)); 263: } 264: 265: move() { 266: if (this.counter < 200) { 267: if (this.x === this.targetX && this.y === this.targetY) { 268: // UFOの目標点のX座標、Y座標の最大値。 269: // UFOはUFO_SPPEDドットずつ動くので、 実際にはこれにUFO_SPEEDを掛ける。 270: const ufoTargetXMax = Math.floor(canvas.width / UFO_SPEED) + 1; 271: const ufoTargetYMax = Math.floor((canvas.height - 100) / UFO_SPEED) + 1; 272: this.targetX = (Math.floor(Math.random() * ufoTargetXMax) * UFO_SPEED); 273: this.targetY = (Math.floor(Math.random() * ufoTargetYMax) * UFO_SPEED); 274: } 275: if (this.x < this.targetX) { 276: this.x += UFO_SPEED; 277: } else if (this.x > this.targetX) { 278: this.x -= UFO_SPEED; 279: } 280: if (this.y < this.targetY) { 281: this.y += UFO_SPEED; 282: } else if (this.y > this.targetY) { 283: this.y -= UFO_SPEED; 284: } 285: this.counter++; 286: } else { 287: this.y -= UFO_EXIT_SPEED; 288: if (this.y < 0) { 289: ufoFlag = false; 290: return AfterMove.DELETE; 291: } 292: } 293: return AfterMove.NORMAL; 294: } 295: 296: hitTest(x, y) { 297: const hitBox = this.getHitBox(); 298: if (x > hitBox.xMin && x < hitBox.xMax 299: && y > hitBox.yMin && y < hitBox.yMax) { 300: ufoFlag = false; 301: return HitTestResult.HIT_AND_DESTROY; 302: } 303: return HitTestResult.NOT_HIT; 304: } 305: 306: getHitBox() { 307: return new HitBox(this.x - (ufoImage.width / 2), 308: this.y - (ufoImage.height / 2), 309: this.x + (ufoImage.width / 2), 310: this.y + (ufoImage.height / 2)); 311: } 312: } 313: 314: class EnemyBomb { 315: constructor(x, y) { 316: this.x = x; 317: this.y = y; 318: } 319: 320: move() { 321: this.y += 8; 322: } 323: 324: draw(context) { 325: context.fillStyle = "pink"; 326: context.beginPath(); 327: context.arc(this.x, this.y, 3, 0, Math.PI * 2); 328: context.fill(); 329: } 330: 331: hitTest(x, y) { 332: return HitTestResult.NOT_HIT; 333: } 334: 335: getHitBox() { 336: return new HitBox(this.x - 1, this.y - 1, this.x + 1, this.y + 1); 337: } 338: } 339: 340: class Explosion { 341: constructor(x, y) { 342: this.x = x; 343: this.y = y; 344: this.counter = -1; 345: } 346: 347: draw(context) { 348: const currentImage = explosionImage[this.counter]; 349: context.drawImage(currentImage, 350: this.x - (currentImage.width / 2), 351: this.y - (currentImage.height / 2)); 352: } 353: } 354: 355: class HitBox { 356: constructor(xMin, yMin, xMax, yMax) { 357: this.xMin = xMin; 358: this.yMin = yMin; 359: this.xMax = xMax; 360: this.yMax = yMax; 361: } 362: } 363: 364: function initialize() { 365: ufoFlag = false; 366: cannonX = (canvas.width - cannonImage.width) / 2; 367: leftKeyPressed = false; 368: rightKeyPressed = false; 369: gameOverFlag = false; 370: 371: beams = []; 372: enemies = []; 373: explosions = []; 374: 375: mainLoop(); 376: } 377: 378: function mainLoop() { 379: // 新しい敵の出現処理 380: if (Math.random() < 0.01) { 381: enemies.push(new Alien()); 382: } else if (Math.random() < 0.02) { 383: enemies.push(new FlyingBoard()); 384: } else if (Math.random() < 0.01 && !ufoFlag) { 385: enemies.push(new Ufo()); 386: } 387: 388: // 画面をクリアする 389: context.clearRect(0, 0, canvas.width, canvas.height); 390: 391: // 敵を描く 392: for (let i = 0; i < enemies.length; i++) { 393: enemies[i].draw(context); 394: } 395: // キャノン砲を描く 396: if (!gameOverFlag) { 397: // 通常状態 398: context.drawImage(cannonImage, 399: cannonX - (cannonImage.width / 2), 400: canvas.height - cannonImage.height - BOTTOM_MARGIN); 401: } else { 402: // ゲームオーバー表示時は爆発パターンを描く 403: const expCounter = Math.floor(cannonExplosionCounter / 3); 404: if (expCounter < cannonExplosionImage.length) { 405: const explosionImage = cannonExplosionImage[expCounter]; 406: context.drawImage(explosionImage, 407: cannonX - (explosionImage.width / 2), 408: canvas.height - BOTTOM_MARGIN - explosionImage.height); 409: cannonExplosionCounter++; 410: } 411: } 412: for (let i = 0; i < beams.length; i++) { 413: beams[i].draw(context); 414: } 415: for (let i = 0; i < explosions.length; i++) { 416: explosions[i].draw(context); 417: } 418: 419: // ビームと敵の衝突判定 420: for (let beamIdx = 0; beamIdx < beams.length; ) { 421: let beamHitFlag = false; 422: 423: for (let enemyIdx = 0; enemyIdx < enemies.length; ) { 424: const hit = enemies[enemyIdx].hitTest(beams[beamIdx].x, beams[beamIdx].y); 425: if (hit === HitTestResult.HIT || hit === HitTestResult.HIT_AND_DESTROY) { 426: beamHitFlag = true; 427: if (hit === HitTestResult.HIT_AND_DESTROY) { 428: explosions.push(new Explosion(enemies[enemyIdx].x, enemies[enemyIdx].y)); 429: enemies.splice(enemyIdx, 1); 430: } else { 431: enemyIdx++; 432: } 433: break; 434: } else { 435: enemyIdx++; 436: } 437: } 438: if (beamHitFlag) { 439: beams.splice(beamIdx, 1) 440: } else { 441: beamIdx++; 442: } 443: } 444: 445: if (!gameOverFlag) { 446: // 敵とキャノン砲の衝突判定 447: for (let enemyIdx = 0; enemyIdx < enemies.length; enemyIdx++) { 448: const hitBox = enemies[enemyIdx].getHitBox(); 449: if (!(hitBox.xMin > (cannonX + cannonImage.width / 2) 450: || hitBox.yMin > canvas.height - BOTTOM_MARGIN 451: || hitBox.xMax < (cannonX - cannonImage.width / 2) 452: || hitBox.yMax < canvas.height - cannonImage.height - BOTTOM_MARGIN)) { 453: gameOverFlag = true; 454: leftKeyPressed = false; 455: rightKeyPressed = false; 456: gameOverCounter = 0; 457: cannonExplosionCounter = 0; 458: } 459: } 460: } 461: for (let i = 0; i < enemies.length; ) { 462: const moveStatus = enemies[i].move(); 463: if (enemies[i].y > canvas.height + 50 || moveStatus === AfterMove.DELETE) { 464: enemies.splice(i, 1); 465: } else { 466: i++; 467: } 468: } 469: if (leftKeyPressed && cannonX > 0) { 470: cannonX -= CANNON_SPEED; 471: } 472: if (rightKeyPressed && cannonX < canvas.width) { 473: cannonX += CANNON_SPEED; 474: } 475: for (let i = 0; i < beams.length; ) { 476: beams[i].move(); 477: if (beams[i].y < 0) { 478: beams.splice(i, 1); 479: } else { 480: i++; 481: } 482: } 483: for (let i = 0; i < explosions.length; ) { 484: explosions[i].counter++; 485: if (explosions[i].counter === explosionImage.length) { 486: explosions.splice(i, 1); 487: } else { 488: i++; 489: } 490: } 491: 492: if (gameOverFlag) { 493: gameOverCounter++; 494: if (gameOverCounter > 200) { 495: initialize(); 496: return; 497: } 498: } 499: setTimeout(mainLoop, 20); 500: } 501: 502: function keyDown(e) { 503: if (gameOverFlag) { 504: return; 505: } 506: if (e.code === "ArrowLeft") { 507: leftKeyPressed = true; 508: } else if (e.code === "ArrowRight") { 509: rightKeyPressed = true; 510: } else if (e.code === "Space" && beams.length < 10 && spaceKeyReleased) { 511: beams.push(new Beam(cannonX, 512: canvas.height - BOTTOM_MARGIN - cannonImage.height - BEAM_LENGTH)); 513: spaceKeyReleased = false; 514: } 515: } 516: 517: function keyUp(e) { 518: if (e.code === "ArrowLeft") { 519: leftKeyPressed = false; 520: } else if (e.code === "ArrowRight") { 521: rightKeyPressed = false; 522: } else if (e.code === "Space") { 523: spaceKeyReleased = true; 524: } 525: } 526: 527: function loadImage(fileName) { 528: const image = new Image(); 529: loadingStatus[fileName] = false; 530: image.src = fileName; 531: let loadedAll = true; 532: image.onload = function() { 533: const keys = Object.keys(loadingStatus); 534: loadingStatus[fileName] = true; 535: for (let i = 0; i < keys.length; i++) { 536: if (!loadingStatus[keys[i]]) { 537: loadedAll = false; 538: } 539: } 540: if (loadedAll) { 541: initialize(); 542: } 543: } 544: return image; 545: } 546: </script> 547: </body> 548: </html>
「敵」それぞれのクラスについてはちょっと後回しにするとして、いったん全体の流れについて説明します。
13~43行目は、上で説明した画像読み込みです。
45~53行目あたりのキャンバスを作ったり定数定義のところは(ほぼ)UFOゲームと変わりませんね。
75~77行目で、以下の3つの変数を宣言しています。
75: let gameOverFlag; 76: let gameOverCounter; 77: let cannonExplosionCounter;
今回のゲームでは、キャノン砲がやられると、8枚の画像を使って爆発しますが、爆発している間も敵は動き続けます。世の中のたいていのゲームではそうなっていると思います。そうなると、キャノン砲の爆発を、UFOゲームにおけるUFOの爆発のように別関数で行うわけにもいきません。そこで、mainLoop()内で、ゲームオーバー後なのかそうでないかを示すフラグを導入しました。それがgameOverFlagです。
リストを見るとわかるとおり、gameOverFlagは以下の箇所で判定に使われています。
キャノン砲が爆発してしばらくしたら、また新しいゲームが始まります。この「しばらくしたら」をカウントするのがgameOverCounterで、494~500行目のところで、カウンタが200を超えたところで(mainLoop()が200回呼ばれたところで)initialize()を呼び出してゲームを最初から始めなおしています。
キャノン砲の爆発画像は8枚用意しましたが、これを順番に切り替えるためのカウンタがcannonExplosionCounterです。403行目でこれを3で割って、それを配列の添字に使っているので、3コマは同じ画像を表示し続けます。これは8枚の画像でできるだけ長く爆発を見せたいからで、これ以上たくさんペイントで画像を作るのは私の労力的に無理でした。
79~81行目で、以下の3つの配列を用意しています。
79: let beams = []; 80: let enemies = []; 81: let explosions = [];
beamsは(UFOゲーム同様)ビームの配列、enemiesは敵の配列、explosionsは敵が破壊されたときに表示される爆発の配列です。今回はビームもクラスとし、draw()とmove()のメソッドを付けました(86~103行目)。爆発もクラスにしていますが(340~359行目)、こちらは動かないのでmove()メソッドはありません。
このゲームの本体はUFOゲーム同様mainLoop()関数です。冒頭で、新しい敵の出現の処理を行っています。
379: // 新しい敵の出現処理 380: if (Math.random() < 0.01) { 381: enemies.push(new Alien()); 382: } else if (Math.random() < 0.02) { 383: enemies.push(new FlyingBoard()); 384: } else if (Math.random() < 0.01 && !ufoFlag) { 385: enemies.push(new Ufo()); 386: }
Math.random()で乱数を発生し、一定の確率で、新しく敵のインスタンスをnewしてenemies配列に追加しています。この0.01とか0.02とかの数字をいじれば、敵の出現頻度が変わります。
そして、mainLoop()関数の中で、AlienとかFlyingBoardとかUfoとか、敵の種類を表すクラスが登場するのは、唯一この箇所だけです。つまり、この先、このゲームの敵の種類を増やそうと思ったら、追加する敵のクラスを書き足して、この「新しい敵の出現処理」のところに出現処理を追加すれば、mainLoop()の他の部分は一切触らなくてよいということです。この手のゲームではたくさんの種類の敵が出てくるのが普通なので、これは重要なメリットでしょう。オブジェクト指向におけるポリモーフィズムにより、それが可能になっているわけです。
具体的にソースを見ていきましょう。389行目の画面クリアについてはUFOゲームと同じなので飛ばして、391行目からの敵を描画するところは以下のようになっています。
391: // 敵を描く
392: for (let i = 0; i < enemies.length; i++) {
393: enemies[i].draw(context);
394: }
上の方で書いた例と同じですが、ここに、エイリアンとかフライングボードとかの、敵の種類に依存した処理がないのは分かるでしょう。
395~411行目のキャノン砲の描画については先ほど説明したので飛ばします。
412~414行目のビームの描画は、ビームのdraw()メソッドを呼び出してビームを描画しています。続きの爆発の描画も同様です。
420行目からが「ビームと敵の衝突判定」です。
419: // ビームと敵の衝突判定 420: for (let beamIdx = 0; beamIdx < beams.length; ) { 421: let beamHitFlag = false; 422: 423: for (let enemyIdx = 0; enemyIdx < enemies.length; ) { 424: const hit = enemies[enemyIdx].hitTest(beams[beamIdx].x, beams[beamIdx].y); 425: if (hit === HitTestResult.HIT || hit === HitTestResult.HIT_AND_DESTROY) { 426: beamHitFlag = true; 427: if (hit === HitTestResult.HIT_AND_DESTROY) { 428: explosions.push(new Explosion(enemies[enemyIdx].x, enemies[enemyIdx].y)); 429: enemies.splice(enemyIdx, 1); 430: } else { 431: enemyIdx++; 432: } 433: break; 434: } else { 435: enemyIdx++; 436: } 437: } 438: if (beamHitFlag) { 439: beams.splice(beamIdx, 1) 440: } else { 441: beamIdx++; 442: } 443: }
forループを2重にし、外側のループで「すべてのビーム」、内側のループで「すべての敵」についてループしています。424行目で、敵のhitTest()メソッドを、引数にビームのX, Y座標を渡して呼び出していますので、ここで衝突判定が行われます。ビームが当たった場合は(敵を破壊したかどうかはともかく)、beamHitFlagというフラグを立てて(426行目)、さらに敵を破壊していた場合は、explosions配列に爆発を表すExplosionクラスのインスタンスをnewして追加し(428行目)、その敵をenemies配列から除外しています(429行目)。
445行目からが、敵(爆弾含む)とキャノン砲の衝突判定です。
445: if (!gameOverFlag) { 446: // 敵とキャノン砲の衝突判定 447: for (let enemyIdx = 0; enemyIdx < enemies.length; enemyIdx++) { 448: const hitBox = enemies[enemyIdx].getHitBox(); 449: if (!(hitBox.xMin > (cannonX + cannonImage.width / 2) 450: || hitBox.yMin > canvas.height - BOTTOM_MARGIN 451: || hitBox.xMax < (cannonX - cannonImage.width / 2) 452: || hitBox.yMax < canvas.height - cannonImage.height - BOTTOM_MARGIN)) { 453: gameOverFlag = true; 454: leftKeyPressed = false; 455: rightKeyPressed = false; 456: gameOverCounter = 0; 457: cannonExplosionCounter = 0; 458: } 459: } 460: }
448行目のgetHitBox()の呼び出しで、敵の「衝突判定のある長方形」を取得しています。現状では、エイリアン、フライングボード、UFOともに、その敵の画像の領域をそのまま返しています。よってエイリアン画像の角の黒いところが、キャノン砲の角の黒いところをかすっただけでもキャノン砲はやられてしまいます。当たってないじゃないか! と思うかもしれませんが、まあそういうもんだと思ってください。
449行目のif文の条件式が、少々ややこしいので説明しておきます。
このif文では、たとえば敵の領域(hitBox)の左端(xMin)と、キャノン砲の領域の右端を比較しています。これで、敵の左端がキャノン砲の右端よりも大きい(右にある)とすれば、敵は、キャノン砲から見て完全に右にいます。下の図6は、エイリアンの下端がキャノン砲の上端より上にいる、つまりエイリアンがキャノン砲の「完全に上にいる」状態を示しています。
この要領で敵がキャノン砲から見て「完全に右にいる」「完全に下にいる」「完全に左にいる」「完全に上にいる」かどうかを判定し、この4つの条件がひとつも成り立たなければ、敵とキャノン砲の領域はかぶっています(衝突しています)。ひとつも成り立たない、とは、どれかが成り立つ、の否定なので、単項論理否定演算子「!」(こちらを参照)を使って結果を逆転させています。
mainLoop()の続きでは、461~468行目のところで、敵の移動を行っています。
461: for (let i = 0; i < enemies.length; ) { 462: const moveStatus = enemies[i].move(); 463: if (enemies[i].y > canvas.height + 50 || moveStatus === AfterMove.DELETE) { 464: enemies.splice(i, 1); 465: } else { 466: i++; 467: } 468: }
for文で、敵のmove()メソッドを順に呼び出し、画面の下端より下に行くか、move()メソッドがAfterMove.DELETEを返したときにはその敵を配列から削除しています。
その他、483~490行目のところで、敵を破壊した時の爆発画像のカウンタを進めています。
483: for (let i = 0; i < explosions.length; ) { 484: explosions[i].counter++; 485: if (explosions[i].counter === explosionImage.length) { 486: explosions.splice(i, 1); 487: } else { 488: i++; 489: } 490: }
他の箇所は、ほぼUFOゲームと同じですね。
mainLoop()関数はまあそれなりには長いですが(100行ちょっと)、UFOゲームと比べ、いろいろな種類の敵が出てくるこのシューティングゲームとしては「思ったより複雑になっていない」と思えるのではないでしょうか。しかもこの後敵の種類を増やしても、敵の出現処理のところ以外は修正の必要はありません。これがポリモルフィズムの効果です。
ここまで、mainLoop()関数を含め、シューティングゲームのソースの「クラス定義以外の部分」を読んできました。
ここからは、Alienクラスを例に、クラス定義の部分を見ていきます。ゲームをやってみればわかるでしょうが、エイリアンが敵の中で一番複雑な動きをします。これが理解できれば、他のクラスもわかることでしょう。
まず、Alienクラスの前に、エイリアンの状態を定義する列挙型を作っています。
105: // エイリアンの状態を示す列挙型 106: const AlienMode = { 107: TO_RIGHT : 0, // 右に移動中 108: TO_LEFT : 1, // 左に移動中 109: TURNING : 2, // 旋回中 110: FALLING : 3 // 落下中 111: }; 112: Object.freeze(AlienMode);
このゲームでのエイリアンは、最初のうちは左右に動き、その後くるっと回ってキャノン砲めがけて突っ込んできます。この状態を示す列挙型がAlienModeです(mode(モード)というのは、「状態」を意味します)。
最初のうちの右に移動している間はAlienMode.TO_RIGHT、左に移動している間はTO_LEFT、旋回中がTURNING、キャノン砲に向けて突っ込んで来るときはFALLINGです。
続いていよいよAlienクラスの定義を見ていきます。
114: class Alien { 115: constructor() { 116: this.currentImage = alienImageNormal; 117: this.x = 0; 118: this.y = Math.random() * 300 + (this.currentImage.height / 2); 119: this.mode = AlienMode.TO_RIGHT; 120: }
最初はコンストラクタです。ここでエイリアンの登場時の初期化を行っています。
116行目で「this.currentImage」に対し、alienImageNormalという画像を設定しています。current(カレント)というのは「現在の」という意味で、エイリアンはいろいろ画像を差し替えながら動きますが「現在の画像」を最初はalienImageNormalに設定するということです。alienImageNormalというのは29行目でalien_normal.pngから読み込んでいる画像で、実体としてはこの形です。
29: const alienImageNormal = loadImage("./alien_normal.png");
その後、「this.x」すなわちX座標を0に設定し(エイリアンは左端から登場するので)、Y座標は画面の上から300ピクセルの範囲で乱数で決めています。そして、最初はエイリアンは右に向かって動くので、エイリアンのモード(this.mode)をAlienMode.TO_RIGHTに設定しています。
次はdraw()メソッドです。
122: draw(context) { 123: context.drawImage(this.currentImage, 124: this.x - (this.currentImage.width / 2), 125: this.y - (this.currentImage.height / 2)); 126: }
現在this.currentImageに設定されている画像を、this.xとthis.yが中心に来るように描画しています。
さて、次はいよいよ最大の大物であるmove()メソッドです。このメソッドは長いので、説明はコメント形式でソース中に入れました(赤字部分)。
128: move() { 129: if (this.mode === AlienMode.TO_RIGHT || this.mode === AlienMode.TO_LEFT) { // エイリアンが左右移動中の間は、一定の確率で、 // 「旋回中」に切り替わる。 130: if (Math.random() < 0.005) { 131: this.mode = AlienMode.TURNING; // 「旋回中」の段階を示すカウンタを初期化 132: this.counter = 1; 133: } else { 134: if (this.mode === AlienMode.TO_RIGHT) { // 右移動中。右端に達したら折り返す 135: if (this.x < canvas.width - this.currentImage.width / 2) { 136: this.x += 5; 137: } else { 138: this.x -= 5; 139: this.mode = AlienMode.TO_LEFT; 140: } 141: } else { // 左移動中。左端に達したら折り返す 142: if (this.x > this.currentImage.width / 2) { 143: this.x -= 5; 144: } else { 145: this.x += 5; 146: this.mode = AlienMode.TO_RIGHT; 147: } 148: } 149: } 150: } else if (this.mode === AlienMode.TURNING) { // 旋回中は、カウンタに応じて、ちょっとずつ移動しつつ画像を切り替える 151: if (this.counter === 1) { 152: this.currentImage = alienImageR1; 153: this.x += 5; 154: } else if (this.counter === 2) { 155: this.currentImage = alienImageR2; 156: this.x += 5; 157: this.y += 5; 158: } else if (this.counter === 3) { 159: this.currentImage = alienImageR3; 160: this.x += 5; 161: this.y += 8; 162: } else if (this.counter === 4) { 163: this.currentImage = alienImageR4; 164: this.x += 5; 165: this.y += 10; 166: } else if (this.counter === 5) { // カウンタが5になったら、キャノン砲めがけて突っ込んでくる // ここの計算については後述 167: this.mode = AlienMode.FALLING; 168: const hypotenuse = Math.sqrt((cannonX - this.x) * (cannonX - this.x) 169: + (canvas.height - this.y) * (canvas.height - this.y)); 170: this.xSpeed = (cannonX - this.x) / hypotenuse * 10; 171: this.ySpeed = (canvas.height - this.y) / hypotenuse * 10; 172: if (this.xSpeed < 0) { 173: this.currentImage = alienImageL4; 174: } 175: } 176: this.counter++; 177: } else if (this.mode === AlienMode.FALLING) { // 落下中は、X, Y座標にそれぞれxSpeed, ySpeedを加算して動く 178: this.x += this.xSpeed; 179: this.y += this.ySpeed; 180: } 181: 182: if (this.mode !== AlienMode.TURNING) { // 旋回中以外は、一定確率で爆弾を落とす 183: if (Math.random() < 0.01) { 184: enemies.push(new EnemyBomb(this.x, this.y)); 185: } 186: } 187: return AfterMove.NORMAL; 188: }
AlienMode列挙型にあるとおり、エイリアンは大きく分けて、右に移動中、左に移動中、旋回中、落下中の4つの状態を持ちます。move()メソッドも、その状態ごとにif文で処理を分けてます。
それぞれの状態での動きはコメントを見ればわかるかと思いますが、「旋回中」から「落下中」に変化するところ(167行目)でキャノン砲に突っ込んでくる方向を決定する部分は図で示さないとわからないでしょうから、ここで説明します。
ここでやることは、エイリアンがキャノン砲に突っ込んでくるときの、X軸方向、Y軸方向それぞれの1回あたりの移動量(スピード)を求めることです。上の方で説明した図2を再掲します。
X軸, Y軸それぞれのスピードが決まったら、落下中は、その値をX座標、Y座標に足していけばよいわけです(178~179行目)。
X軸方向のスピードと、Y軸方向のスピードの「比率」が、エイリアンの移動する「方向」になります。そしてその比率は、エイリアンの位置からキャノン砲の位置までのX座標、Y座標それぞれの差の比率と同じです(図7)。
雑な作りにするなら、たとえば「Y軸方向のスピードは常に一定とする」という方法でも、ゲームとして遊べないわけではありません。しかし、それだと、キャノン砲とエイリアンが横方向に離れている場合、スピードが速くなって結構不自然です。ためしに作ってここに置いてみました。どうでしょうか。
一応、このプログラムの計算方法も載せておきます。
this.ySpeed = 7; this.xSpeed = (cannonX - this.x) / (canvas.height - this.y) * this.ySpeed;
Y軸方向のスピードは7で固定、X軸方向のスピードは、まず、X軸方向のキャノン砲との距離を、Y軸方向の距離で割ります。「図7の長方形を、Y軸方向の長さが1になるよう縮小した」と考えればよいでしょう。それにY軸方向のスピードを掛ければ、Y軸方向のスピードに相当するX軸方向のスピードが得られます。
なお、ここでは、簡単にするためキャノン砲の高さとBOTTOM_MARGINは無視しています。なのでエイリアンはキャノン砲のちょっと下に突っ込んできます。
これで満足するならそれでもよいですが、やはりこれでは変なので、エイリアンの全体としての移動速度、つまり図7における赤い矢印の長さを一定にする方法を考えます。ここでは、三平方の定理を使います。
三平方の定理(ピタゴラスの定理とも呼ぶ)は、中学の3年くらいで習うようですが、まだ習ってない人は今ここで覚えてしまいましょう。三平方の定理とは、
直角三角形の斜辺の長さの2乗は、残りの2辺の長さの2乗の輪に等しい
という法則です。「2乗」というのは同じ数を2回掛け合わせるという意味で、たとえば5の2乗なら5×5の25になります。
この「c2 = a2 + b2」という式の両辺について平方根を求めると、(等しいものの平方根は等しいので)以下のように変形できます。「平方根」というのは、2乗するとその数になる数のことで、たとえば25の平方根は5です。
この線が折れ曲がったような妙な記号は、ルート(root)といって、平方根を表す記号です。
このように、三平方の定理を使うと、直角三角形の斜辺以外の2辺の長さから、斜辺の長さを求めることができます。「縦と横の長さから斜めの長さを求めることができる」と言ってもよいでしょう。
さて、これをエイリアンの動きに応用してみます。
上図は、図7に三平方の定理を当てはめたものです。三平方の定理により、キャノン砲とエイリアンのX軸、Y軸それぞれの距離(a, b)から、斜辺cの長さを求めることができます。そうしたら、a, bの値をそれぞれcで割ってやれば、この直角三角形を、斜辺の長さが1になるまで縮小できます。そのうえで、エイリアンのスピードを掛けてやれば、cの長さがそのスピードになるaとbの値が決まります。
これをやっているのが、リストの以下の部分です。Math.sqrt()という関数を使っていますが、これはJavaScriptで平方根を求める関数です。sqrtというのはsquare root(スクエアルート)の略で、スクエアは四角形を、ルートは根を意味します。
// 三平方の定理により、斜辺hypotenuseの長さを求める 168: const hypotenuse = Math.sqrt((cannonX - this.x) * (cannonX - this.x) 169: + (canvas.height - this.y) * (canvas.height - this.y)); // X軸, Y軸それぞれのキャノン砲との距離を斜辺の長さで割ったうえで、 // エイリアンのスピード(今回は10)を掛ける 170: this.xSpeed = (cannonX - this.x) / hypotenuse * 10; 171: this.ySpeed = (canvas.height - this.y) / hypotenuse * 10;
hypotenuseというのは「斜辺」を意味する英語です。これは私も知らなかったので辞書を引きました……
今回、エイリアンのX軸、Y軸方向の移動量を求めるにあたり、エイリアンは「キャノン砲のめがけて突っ込んでくる」という動きだったので、エイリアンとキャノン砲の座標を比較することで、X座標の移動量とY座標の移動量の比がまずわかりました。あとは三平方の定理を使って斜め方向の移動量が一定になるように調整すれば、エイリアンを動かすことができました。
今回のケースはそれでよかったのですが、場合によっては、「X座標の移動量とY座標の移動量の比」が最初に決まるのではなく、「角度」からX軸、Y軸方向の移動量を求めたいときもあります。シューティングゲームなら、たとえばこんな感じに、周囲に弾をばらまく敵がいたりするでしょう(「発射!」ボタンを押してください)。
このプログラムでは、角度を少しずつずらしながら弾を発射しています(1周で20発、2周しています)。こういうことをやるためには、三角関数が必要になってきます。
三角関数というのは、いわゆるサインコサインタンジェントというやつですが、ここではtan(タンジェント)はちょっと置いておいて、sin(サイン)とcos(コサイン)について説明します。
中心が原点(0, 0)にあって半径が1の円(単位円と呼びます)の中心から円周に向かって線を引きます。ここで、X軸からその線の間の角度をθ(シータ※4)としたときに、円周上の点のX座標がcos θ、Y座標がsin θです。
JavaScriptでは、sinとcosはそれぞれ関数Math.sin()とMath.cos()で求めることができます。ただし、ここでMath.sin()とかに引数で渡す角度の単位は、小学校で習う1周360°の角度(degree(ディグリー)と呼びます)ではなくて、1周が2πの、ラジアンという単位を使います。π(パイ)は円周率(3.14159265…)で、円の1周の長さは直径×円周率ですから、ラジアンは、その角度で作られる単位円上の円周の長さを意味します※5。
三角関数を使うと、ある角度に対して、その方向に1進むにはX座標にいくつ(こっちがcos)、Y座標にいくつ(こっちがsin)足せばよいのかわかるので、上の「全方向に弾をばらまく」ようなプログラムも書けるわけです。
たとえば、以下は、中心から半径100のところに等間隔に40個、小さな四角を描くプログラムです。
// canvasとcontextを取得する。 // 「trigonometric」とは「三角関数」の意味。 const canvas = document.getElementById("trigonometric_canvas"); const context = canvas.getContext("2d"); // canvasの中心を求める。 const centerX = canvas.width / 2; const centerY = canvas.height / 2; context.fillStyle = "white"; // angleを0から2πまでπ/20ずつ増やしながら、その角度の、距離100のところに、 // 縦横3ピクセルの正方形を描く。 for (let angle = 0; angle < 2 * Math.PI; angle += Math.PI / 20) { // Math.cosとMath.sinで半径1の時のX座標の距離、Y座標の距離を求め、 // それに半径100を掛けている。 // 「- 1」で1引いてるのは、縦横3ピクセルの正方形の中心をその場所に置くため。 context.fillRect(centerX + (Math.cos(angle) * 100) - 1, centerY + (Math.sin(angle) * 100) - 1, 3, 3); }
実行結果
――ところで、三角関数というと、どうも「三角関数なんて学校を出たら使わない。こんなものを教えるくらいなら云々」といった主張をする人がちょくちょくいます。最近だと2022年5月に、国会議員の藤巻健太議員(日本維新の会)がそんな発言をしていました。
そりゃまあ実際「大人になってから三角関数なんて一度も使ったことがない」という人も世の中には多いのでしょうが、このとおり、ちょっとしたゲームを作るにも三角関数は必要なわけで、私自身、プログラマとして就職して割とすぐに「矢印」を描かなければいけない仕事があって三角関数を使った記憶があります。大人になったら、「三角関数を使って矢印を描きなさい」なんて誰も言ってくれないので、必要になったら調べて勉強しなおす、ということもできないでしょう。
三平方の定理や三角関数に限らず、受験で使う数学は、大人になっても使う機会はちょくちょくあるものです(特に研究職とかにならなくても)。中高生の皆さんはちゃんと勉強しておきましょう――とは言っても、「何に使うかわからない」のでは勉強していても意味が分からないですよね。プログラミングでこうして三角関数や三平方の定理を実際に使ってみれば、それが何の役に立つのか、実感としてわかるのではないでしょうか。
ここまでで、シューティングゲームはひととおり動作しました。
これで終わりにしてもよいかもしれませんが、プログラムにはまだ気になるところがあります。それは、「同じようなコードが多すぎる」ことです。
たとえばエイリアンとフライングボードのdraw()メソッドは、まったく同じです。
122: draw(context) { 123: context.drawImage(this.currentImage, 124: this.x - (this.currentImage.width / 2), 125: this.y - (this.currentImage.height / 2)); 126: }
215: draw(context) { 216: context.drawImage(this.currentImage, 217: this.x - (this.currentImage.width / 2), 218: this.y - (this.currentImage.height / 2)); 219: }
UFOでは、画像を切り替える必要がないのでcurrentImageは登場しませんが、これも似たようなものです。
259: draw(context) { 260: context.drawImage(ufoImage, 261: this.x - (ufoImage.width / 2), 262: this.y - (ufoImage.height / 2)); 263: }
getHitBox()も同様。
198: getHitBox() { 199: return new HitBox(this.x - (this.currentImage.width / 2), 200: this.y - (this.currentImage.height / 2), 201: this.x + (this.currentImage.width / 2), 202: this.y + (this.currentImage.height / 2)); 203: }
241: getHitBox() { 242: return new HitBox(this.x - (this.currentImage.width / 2), 243: this.y - (this.currentImage.height / 2), 244: this.x + (this.currentImage.width / 2), 245: this.y + (this.currentImage.height / 2)); 246: }
306: getHitBox() { 307: return new HitBox(this.x - (ufoImage.width / 2), 308: this.y - (ufoImage.height / 2), 309: this.x + (ufoImage.width / 2), 310: this.y + (ufoImage.height / 2)); 311: }
ビームが当たったかどうかを判定するhitTest()メソッドは、フライングボードは5発目まで壊れなかったり、UFOは同時に1機しか出てこないのでその制御をしているフラグを倒すとかの違いはありますが、getHitBox()で自身の範囲を取ってきて、x,yと比較するあたりの処理は似通っています。
190: hitTest(x, y) { 191: const hitBox = this.getHitBox(); 192: if (x > hitBox.xMin && x < hitBox.xMax 193: && y > hitBox.yMin && y < hitBox.yMax) { 194: return HitTestResult.HIT_AND_DESTROY; 195: } 196: }
228: hitTest(x, y) { 229: const hitBox = this.getHitBox(); 230: if (x > hitBox.xMin && x < hitBox.xMax 231: && y > hitBox.yMin && y < hitBox.yMax) { 232: this.hitCounter++; 233: if (this.hitCounter == 5) { 234: return HitTestResult.HIT_AND_DESTROY; 235: } else { 236: return HitTestResult.HIT; 237: } 238: } 239: }
296: hitTest(x, y) { 297: const hitBox = this.getHitBox(); 298: if (x > hitBox.xMin && x < hitBox.xMax 299: && y > hitBox.yMin && y < hitBox.yMax) { 300: ufoFlag = false; 301: return HitTestResult.HIT_AND_DESTROY; 302: } 303: return HitTestResult.NOT_HIT; 304: }
こうして見ていくと、さすがにmove()メソッドはそれぞれ動きが異なるのでまったく別物ですが、他のメソッドはかなり似通っていることがわかります。大きく違うのは、敵の爆弾(EnemyBomb)くらいでしょう。
「プログラムに似通った部分が多いからってそれが何だっての? エディタでコピー/貼り付け(ペースト)すれば手で打ち込む必要もないんだから、別に困らないんじゃないの?」と思う人もいるでしょう。しかし、本来同じであるべきプログラムを、そうやってコピー&ペースト(コピペ)でぺたぺた増やしてしまうと、バグがあったり、何か動きを変えたくてプログラムを直すとき、コピペで増やしたコードをすべて直して回らなければいけません。そういうことをやっていると、いつか直し忘れたり、ちょっと違う修正をしてしまって収拾のつかないことになりがちです。一般に、プログラミングにおいて、コピペは悪です。
こういう時のために、オブジェクト指向プログラミング言語では、継承(inheritance/インヘリタンス)という機能が用意されています。
「継承」とは、何らかのクラスをベースとして、そこから派生(はせい)したクラスを作ることです。たとえば今回のケースなら、「敵」(Enemy)クラスをまず作り、それを継承してエイリアンとかフライングボードとかUFOとか敵の爆弾とかのクラスを作ります。この時、親となる「敵」クラスをスーパークラス(super class)、そこから派生して作られるエイリアンとかのクラスをサブクラスと呼びます。
そして、どんな敵でもだいたい共通の処理は、スーパークラスである「敵」クラスのメソッドとして書き、敵の種類ごとに処理を変えたければ、サブクラスで別途メソッドを書いてスーパークラスのメソッドの挙動を上書きします。これをメソッドオーバーライド(method override)と呼びます。
では、スーパークラスである敵(Enemy)クラスを書くために、このゲームにおける「敵」の共通点を洗い出していきましょう。
こう考えると、以下のように役割分担するのがよさそうです。
メソッド | スーパークラス(Enemy) | 各サブクラス | サブクラスでの扱い |
---|---|---|---|
move() | 実装しない | エイリアン | オーバーライドする |
フライングボード | オーバーライドする | ||
UFO | オーバーライドする | ||
敵の爆弾 | オーバーライドする | ||
draw() | currentImageを描画する | エイリアン | スーパークラスのものをそのまま使う |
フライングボード | スーパークラスのものをそのまま使う | ||
UFO | スーパークラスのものをそのまま使う | ||
敵の爆弾 | オーバーライドする | ||
hitTest() | 画像の範囲と重なっていたら、決められた回数で破壊される | エイリアン | スーパークラスのものをそのまま使う(「決められた回数」を1とする) |
フライングボード | スーパークラスのものをそのまま使う(「決められた回数」を5とする) | ||
UFO | オーバーライドする(UFOの出現フラグを倒さなければいけないので) | ||
敵の爆弾 | オーバーライドする(ビームはすり抜けるので) | ||
getHitBox() | 画像の範囲を返す | エイリアン | スーパークラスのものをそのまま使う |
フライングボード | スーパークラスのものをそのまま使う | ||
UFO | スーパークラスのものをそのまま使う | ||
敵の爆弾 | オーバーライドする |
以下、継承を使って描きなおした全ソースです。
1: <!DOCTYPE html> 2: <html lang="ja"> 3: <head> 4: <meta charset="UTF-8"> 5: <title>シューティングゲーム</title> 6: </head> 7: <body> 8: <h1>シューティングゲーム</h1> 9: <canvas id="canvas" width="800px" height="600px" style="background-color:black;"></canvas> 10: <script> 11: "use strict"; 12: 13: // 画像のロードがどこまで終わったかを保持するオブジェクト。 14: // loadImage()関数を参照のこと。 15: const loadingStatus = {}; 16: 17: // 画像のロード 18: const ufoImage = loadImage("./ufo.png"); 19: const cannonImage = loadImage("./cannon.png"); 20: const cannonExplosionImage = new Array(8); 21: cannonExplosionImage[0] = loadImage("./cannon_explosion_01.png"); 22: cannonExplosionImage[1] = loadImage("./cannon_explosion_02.png"); 23: cannonExplosionImage[2] = loadImage("./cannon_explosion_03.png"); 24: cannonExplosionImage[3] = loadImage("./cannon_explosion_04.png"); 25: cannonExplosionImage[4] = loadImage("./cannon_explosion_05.png"); 26: cannonExplosionImage[5] = loadImage("./cannon_explosion_06.png"); 27: cannonExplosionImage[6] = loadImage("./cannon_explosion_07.png"); 28: cannonExplosionImage[7] = loadImage("./cannon_explosion_08.png"); 29: const alienImageNormal = loadImage("./alien_normal.png"); 30: const alienImageR1 = loadImage("./alien_r1.png"); 31: const alienImageR2 = loadImage("./alien_r2.png"); 32: const alienImageR3 = loadImage("./alien_r3.png"); 33: const alienImageR4 = loadImage("./alien_r4.png"); 34: const alienImageL4 = loadImage("./alien_l4.png"); 35: const flyingBoardImage = new Array(4); 36: flyingBoardImage[0] = loadImage("./flyingboard_0.png"); 37: flyingBoardImage[1] = loadImage("./flyingboard_1.png"); 38: flyingBoardImage[2] = loadImage("./flyingboard_2.png"); 39: flyingBoardImage[3] = loadImage("./flyingboard_3.png"); 40: const explosionImage = new Array(3); 41: explosionImage[0] = loadImage("./explosion_0.png"); 42: explosionImage[1] = loadImage("./explosion_1.png"); 43: explosionImage[2] = loadImage("./explosion_2.png"); 44: 45: const canvas = document.getElementById("canvas"); 46: const context = canvas.getContext("2d"); 47: 48: const UFO_SPEED = 10; 49: const UFO_EXIT_SPEED = 5; 50: const CANNON_SPEED = 10; 51: const BEAM_LENGTH = 20; 52: const BEAM_SPEED = 10; 53: const BOTTOM_MARGIN = 5; 54: 55: // ビームの当たり判定を返す列挙型 56: const HitTestResult = { 57: NOT_HIT : 0, // 当たらなかった 58: HIT : 1, // 当たった 59: HIT_AND_DESTROY : 2 // 当たり、破壊された 60: }; 61: Object.freeze(HitTestResult); 62: 63: // 移動後の挙動を示す列挙型 64: const AfterMove = { 65: NORMAL : 0, // 通常 66: DELETE : 1 // enemies配列から削除する 67: }; 68: Object.freeze(AfterMove); 69: 70: let ufoFlag; 71: let cannonX; 72: let leftKeyPressed = false; 73: let rightKeyPressed = false; 74: let spaceKeyReleased = true; 75: let gameOverFlag; 76: let gameOverCounter; 77: let cannonExplosionCounter; 78: 79: let beams = []; 80: let enemies = []; 81: let explosions = []; 82: 83: document.onkeydown = keyDown; 84: document.onkeyup = keyUp; 85: 86: class Beam { 87: constructor(x, y) { 88: this.x = x; 89: this.y = y; 90: } 91: 92: draw(context) { 93: context.strokeStyle = "yellow"; 94: context.beginPath(); 95: context.moveTo(this.x, this.y); 96: context.lineTo(this.x, this.y + 20); 97: context.stroke(); 98: } 99: 100: move() { 101: this.y -= 10; 102: } 103: } 104: 105: // エイリアンの状態を示す列挙型 106: const AlienMode = { 107: TO_RIGHT : 0, // 右に移動中 108: TO_LEFT : 1, // 左に移動中 109: TURNING : 2, // 旋回中 110: FALLING : 3 // 落下中 111: }; 112: Object.freeze(AlienMode); 113: 114: class Enemy { 115: constructor() { 116: this.hitCounter = 0; 117: this.hitLimit = 1; 118: } 119: 120: draw(context) { 121: context.drawImage(this.currentImage, 122: this.x - (this.currentImage.width / 2), 123: this.y - (this.currentImage.height / 2)); 124: } 125: 126: hitTest(x, y) { 127: const hitBox = this.getHitBox(); 128: if (x > hitBox.xMin && x < hitBox.xMax 129: && y > hitBox.yMin && y < hitBox.yMax) { 130: this.hitCounter++; 131: if (this.hitCounter === this.hitLimit) { 132: return HitTestResult.HIT_AND_DESTROY; 133: } else { 134: return HitTestResult.HIT; 135: } 136: } 137: return HitTestResult.NOT_HIT; 138: } 139: 140: getHitBox() { 141: return new HitBox(this.x - (this.currentImage.width / 2), 142: this.y - (this.currentImage.height / 2), 143: this.x + (this.currentImage.width / 2), 144: this.y + (this.currentImage.height / 2)); 145: } 146: } 147: 148: 149: class Alien extends Enemy { 150: constructor() { 151: super(); 152: this.currentImage = alienImageNormal; 153: this.x = 0; 154: this.y = Math.random() * 300 + (this.currentImage.height / 2); 155: this.mode = AlienMode.TO_RIGHT; 156: } 157: 158: move() { 159: if (this.mode === AlienMode.TO_RIGHT || this.mode === AlienMode.TO_LEFT) { 160: if (Math.random() < 0.005) { 161: this.mode = AlienMode.TURNING; 162: this.counter = 1; 163: } else { 164: if (this.mode === AlienMode.TO_RIGHT) { 165: if (this.x < canvas.width - this.currentImage.width / 2) { 166: this.x += 5; 167: } else { 168: this.x -= 5; 169: this.mode = AlienMode.TO_LEFT; 170: } 171: } else { 172: if (this.x > this.currentImage.width / 2) { 173: this.x -= 5; 174: } else { 175: this.x += 5; 176: this.mode = AlienMode.TO_RIGHT; 177: } 178: } 179: } 180: } else if (this.mode === AlienMode.TURNING) { 181: if (this.counter === 1) { 182: this.currentImage = alienImageR1; 183: this.x += 5; 184: } else if (this.counter === 2) { 185: this.currentImage = alienImageR2; 186: this.x += 5; 187: this.y += 5; 188: } else if (this.counter === 3) { 189: this.currentImage = alienImageR3; 190: this.x += 5; 191: this.y += 8; 192: } else if (this.counter === 4) { 193: this.currentImage = alienImageR4; 194: this.x += 5; 195: this.y += 10; 196: } else if (this.counter === 5) { 197: this.mode = AlienMode.FALLING; 198: const hypotenuse = Math.sqrt((cannonX - this.x) * (cannonX - this.x) 199: + (canvas.height - this.y) * (canvas.height - this.y)); 200: this.xSpeed = (cannonX - this.x) / hypotenuse * 10; 201: this.ySpeed = (canvas.height - this.y) / hypotenuse * 10; 202: if (this.xSpeed < 0) { 203: this.currentImage = alienImageL4; 204: } 205: } 206: this.counter++; 207: } else if (this.mode === AlienMode.FALLING) { 208: this.x += this.xSpeed; 209: this.y += this.ySpeed; 210: } 211: 212: if (this.mode !== AlienMode.TURNING) { 213: if (Math.random() < 0.01) { 214: enemies.push(new EnemyBomb(this.x, this.y)); 215: } 216: } 217: return AfterMove.NORMAL; 218: } 219: } 220: 221: class FlyingBoard extends Enemy { 222: constructor() { 223: super(); 224: this.hitLimit = 5; 225: this.x = Math.random() * canvas.width; 226: this.y = 0; 227: this.counter = 0; 228: this.currentImage = flyingBoardImage[0]; 229: } 230: 231: move() { 232: this.y += 5; 233: this.counter++; 234: this.currentImage = flyingBoardImage[Math.floor(this.counter / 3) % 4]; 235: return AfterMove.NORMAL; 236: } 237: } 238: 239: class Ufo extends Enemy { 240: constructor() { 241: super(); 242: this.x = 0; 243: this.y = 0; 244: this.targetX = 0; 245: this.targetY = 0; 246: this.counter = 0; 247: this.currentImage = ufoImage; 248: ufoFlag = true; 249: } 250: 251: move() { 252: if (this.counter < 200) { 253: if (this.x === this.targetX && this.y === this.targetY) { 254: // UFOの目標点のX座標、Y座標の最大値。 255: // UFOはUFO_SPPEDドットずつ動くので、 実際にはこれにUFO_SPEEDを掛ける。 256: const ufoTargetXMax = Math.floor(canvas.width / UFO_SPEED) + 1; 257: const ufoTargetYMax = Math.floor((canvas.height - 100) / UFO_SPEED) + 1; 258: this.targetX = (Math.floor(Math.random() * ufoTargetXMax) * UFO_SPEED); 259: this.targetY = (Math.floor(Math.random() * ufoTargetYMax) * UFO_SPEED); 260: } 261: if (this.x < this.targetX) { 262: this.x += UFO_SPEED; 263: } else if (this.x > this.targetX) { 264: this.x -= UFO_SPEED; 265: } 266: if (this.y < this.targetY) { 267: this.y += UFO_SPEED; 268: } else if (this.y > this.targetY) { 269: this.y -= UFO_SPEED; 270: } 271: this.counter++; 272: } else { 273: this.y -= UFO_EXIT_SPEED; 274: if (this.y < 0) { 275: ufoFlag = false; 276: return AfterMove.DELETE; 277: } 278: } 279: return AfterMove.NORMAL; 280: } 281: 282: hitTest(x, y) { 283: const result = super.hitTest(x, y); 284: if (result === HitTestResult.HIT_AND_DESTROY) { 285: ufoFlag = false; 286: } 287: return result; 288: } 289: } 290: 291: class EnemyBomb extends Enemy { 292: constructor(x, y) { 293: super(); 294: this.x = x; 295: this.y = y; 296: } 297: 298: move() { 299: this.y += 8; 300: } 301: 302: draw(context) { 303: context.fillStyle = "pink"; 304: context.beginPath(); 305: context.arc(this.x, this.y, 3, 0, Math.PI * 2); 306: context.fill(); 307: } 308: 309: hitTest(x, y) { 310: return HitTestResult.NOT_HIT; 311: } 312: 313: getHitBox() { 314: return new HitBox(this.x - 1, this.y - 1, this.x + 1, this.y + 1); 315: } 316: } 317: 318: class Explosion { 319: constructor(x, y) { 320: this.x = x; 321: this.y = y; 322: this.counter = -1; 323: } 324: 325: draw(context) { 326: const currentImage = explosionImage[this.counter]; 327: context.drawImage(currentImage, 328: this.x - (currentImage.width / 2), 329: this.y - (currentImage.height / 2)); 330: } 331: } 332: 333: class HitBox { 334: constructor(xMin, yMin, xMax, yMax) { 335: this.xMin = xMin; 336: this.yMin = yMin; 337: this.xMax = xMax; 338: this.yMax = yMax; 339: } 340: } 341: 342: function initialize() { 343: ufoFlag = false; 344: cannonX = (canvas.width - cannonImage.width) / 2; 345: leftKeyPressed = false; 346: rightKeyPressed = false; 347: gameOverFlag = false; 348: 349: beams = []; 350: enemies = []; 351: explosions = []; 352: 353: mainLoop(); 354: } 355: 356: function mainLoop() { 357: // 新しい敵の出現処理 358: if (Math.random() < 0.01) { 359: enemies.push(new Alien()); 360: } else if (Math.random() < 0.02) { 361: enemies.push(new FlyingBoard()); 362: } else if (Math.random() < 0.01 && !ufoFlag) { 363: enemies.push(new Ufo()); 364: } 365: 366: // 画面をクリアする 367: context.clearRect(0, 0, canvas.width, canvas.height); 368: 369: // 敵を描く 370: for (let i = 0; i < enemies.length; i++) { 371: enemies[i].draw(context); 372: } 373: // キャノン砲を描く 374: if (!gameOverFlag) { 375: // 通常状態 376: context.drawImage(cannonImage, 377: cannonX - (cannonImage.width / 2), 378: canvas.height - cannonImage.height - BOTTOM_MARGIN); 379: } else { 380: // ゲームオーバー表示時は爆発パターンを描く 381: const expCounter = Math.floor(cannonExplosionCounter / 3); 382: if (expCounter < cannonExplosionImage.length) { 383: const explosionImage = cannonExplosionImage[expCounter]; 384: context.drawImage(explosionImage, 385: cannonX - (explosionImage.width / 2), 386: canvas.height - BOTTOM_MARGIN - explosionImage.height); 387: cannonExplosionCounter++; 388: } 389: } 390: for (let i = 0; i < beams.length; i++) { 391: beams[i].draw(context); 392: } 393: for (let i = 0; i < explosions.length; i++) { 394: explosions[i].draw(context); 395: } 396: 397: // ビームと敵の衝突判定 398: for (let beamIdx = 0; beamIdx < beams.length; ) { 399: let beamHitFlag = false; 400: 401: for (let enemyIdx = 0; enemyIdx < enemies.length; ) { 402: const hit = enemies[enemyIdx].hitTest(beams[beamIdx].x, beams[beamIdx].y); 403: if (hit === HitTestResult.HIT || hit === HitTestResult.HIT_AND_DESTROY) { 404: beamHitFlag = true; 405: if (hit === HitTestResult.HIT_AND_DESTROY) { 406: explosions.push(new Explosion(enemies[enemyIdx].x, enemies[enemyIdx].y)); 407: enemies.splice(enemyIdx, 1); 408: } else { 409: enemyIdx++; 410: } 411: break; 412: } else { 413: enemyIdx++; 414: } 415: } 416: if (beamHitFlag) { 417: beams.splice(beamIdx, 1) 418: } else { 419: beamIdx++; 420: } 421: } 422: 423: if (!gameOverFlag) { 424: // 敵とキャノン砲の衝突判定 425: for (let enemyIdx = 0; enemyIdx < enemies.length; enemyIdx++) { 426: const hitBox = enemies[enemyIdx].getHitBox(); 427: if (!(hitBox.xMin > (cannonX + cannonImage.width / 2) 428: || hitBox.yMin > canvas.height - BOTTOM_MARGIN 429: || hitBox.xMax < (cannonX - cannonImage.width / 2) 430: || hitBox.yMax < canvas.height - cannonImage.height - BOTTOM_MARGIN)) { 431: gameOverFlag = true; 432: leftKeyPressed = false; 433: rightKeyPressed = false; 434: gameOverCounter = 0; 435: cannonExplosionCounter = 0; 436: } 437: } 438: } 439: for (let i = 0; i < enemies.length; ) { 440: const moveStatus = enemies[i].move(); 441: if (enemies[i].y > canvas.height + 50 || moveStatus === AfterMove.DELETE) { 442: enemies.splice(i, 1); 443: } else { 444: i++; 445: } 446: } 447: if (leftKeyPressed && cannonX > 0) { 448: cannonX -= CANNON_SPEED; 449: } 450: if (rightKeyPressed && cannonX < canvas.width) { 451: cannonX += CANNON_SPEED; 452: } 453: for (let i = 0; i < beams.length; ) { 454: beams[i].move(); 455: if (beams[i].y < 0) { 456: beams.splice(i, 1); 457: } else { 458: i++; 459: } 460: } 461: for (let i = 0; i < explosions.length; ) { 462: explosions[i].counter++; 463: if (explosions[i].counter === explosionImage.length) { 464: explosions.splice(i, 1); 465: } else { 466: i++; 467: } 468: } 469: 470: if (gameOverFlag) { 471: gameOverCounter++; 472: if (gameOverCounter > 200) { 473: initialize(); 474: return; 475: } 476: } 477: setTimeout(mainLoop, 20); 478: } 479: 480: function keyDown(e) { 481: if (gameOverFlag) { 482: return; 483: } 484: if (e.code === "ArrowLeft") { 485: leftKeyPressed = true; 486: } else if (e.code === "ArrowRight") { 487: rightKeyPressed = true; 488: } else if (e.code === "Space" && beams.length < 10 && spaceKeyReleased) { 489: beams.push(new Beam(cannonX, 490: canvas.height - BOTTOM_MARGIN - cannonImage.height - BEAM_LENGTH)); 491: spaceKeyReleased = false; 492: } 493: } 494: 495: function keyUp(e) { 496: if (e.code === "ArrowLeft") { 497: leftKeyPressed = false; 498: } else if (e.code === "ArrowRight") { 499: rightKeyPressed = false; 500: } else if (e.code === "Space") { 501: spaceKeyReleased = true; 502: } 503: } 504: 505: function loadImage(fileName) { 506: const image = new Image(); 507: loadingStatus[fileName] = false; 508: image.src = fileName; 509: let loadedAll = true; 510: image.onload = function() { 511: const keys = Object.keys(loadingStatus); 512: loadingStatus[fileName] = true; 513: for (let i = 0; i < keys.length; i++) { 514: if (!loadingStatus[keys[i]]) { 515: loadedAll = false; 516: } 517: } 518: if (loadedAll) { 519: initialize(); 520: } 521: } 522: return image; 523: } 524: </script> 525: </body> 526: </html>
リスト3と比べて違うところを見ていきます。まずは今回新たに書いた、すべての敵のスーパークラスであるEnemyクラスです。
114: class Enemy { 115: constructor() { 116: this.hitCounter = 0; 117: this.hitLimit = 1; 118: } 119: 120: draw(context) { 121: context.drawImage(this.currentImage, 122: this.x - (this.currentImage.width / 2), 123: this.y - (this.currentImage.height / 2)); 124: } 125: 126: hitTest(x, y) { 127: const hitBox = this.getHitBox(); 128: if (x > hitBox.xMin && x < hitBox.xMax 129: && y > hitBox.yMin && y < hitBox.yMax) { 130: this.hitCounter++; 131: if (this.hitCounter === this.hitLimit) { 132: return HitTestResult.HIT_AND_DESTROY; 133: } else { 134: return HitTestResult.HIT; 135: } 136: } 137: return HitTestResult.NOT_HIT; 138: } 139: 140: getHitBox() { 141: return new HitBox(this.x - (this.currentImage.width / 2), 142: this.y - (this.currentImage.height / 2), 143: this.x + (this.currentImage.width / 2), 144: this.y + (this.currentImage.height / 2)); 145: } 146: }
このゲームの場合、スーパークラスであるEnemyは、これ自体のインスタンスが作られることはないのですが(こういうクラスを抽象クラス(abstract class)と呼びます)、コンストラクタを書くことができます。コンストラクタでは、現在ビームを何発食らったかを持つhitCounterと、ビームが何回当たれば壊れるかを示すhitLimitを初期化しています。
あとは、表1での説明の通り、draw()、hitTest()、getHitBox()メソッドを定義しています。
次はAlienクラスです。
149: class Alien extends Enemy { 150: constructor() { 151: super(); 152: this.currentImage = alienImageNormal; 153: this.x = 0; 154: this.y = Math.random() * 300 + (this.currentImage.height / 2); 155: this.mode = AlienMode.TO_RIGHT; 156: } 157: 158: move() { 長いので省略 217: return AfterMove.NORMAL; 218: } 219: }
あるスーパークラスを継承してサブクラスを作るときは、extendsというキーワードを使います。今回は敵(Enemy)クラスを継承してAlien等のクラスを作るので、149行目のように、「class Alien extends Enemy」と書きます。こうすることで、AlienクラスにそのスーパークラスであるEnemyのメソッドが引き継がれるわけです。
150行目からはコンストラクタですが、冒頭に「super();」とあります。これはスーパークラス(この場合はEnemy)のコンストラクタの呼び出しを意味します。必要なら引数を渡すこともできます。今回、Enemyにはコンストラクタを書いていますが、これは省略できます。しかし、たとえスーパークラスのコンストラクタを書かなくても、サブクラスの方のコンストラクタではthisを参照する前にスーパークラスのコンストラクタを呼ばなければいけません。
158行目からはmove()メソッドの定義です(長いうえに内容は変わらないので省略しています)。メソッドオーバーライドを使うことで、コンストラクタとmove()以外のメソッドを書かずに済んでいることがわかります。
FlyingBoardも同様にmove()メソッドだけ書いています。同じなので説明は省略します。
UFOは、破壊されたらUFOフラグを倒さなければいけません。そこでhitTest()メソッドは以下のようになっています。
282: hitTest(x, y) { 283: const result = super.hitTest(x, y); 284: if (result === HitTestResult.HIT_AND_DESTROY) { 285: ufoFlag = false; 286: } 287: return result; 288: } 289: }
283行目で「super.hitTest(x, y);」と書いています。このように、super.を付けることで、スーパークラスのメソッドを呼び出すことができます。ここではEnemyのhitTest()で衝突判定を行ったうえで、当たっていたら、ufoFlagを倒すということをしています。
この「完全初心者のためのプログラミング入門」はここまでです。ずいぶん時間はかかりましたが、当初の構想にあった分は書ききりました。
できあがったのは1980年代くらいのレトロなゲームですが、オブジェクト指向にのっとってプログラムを書けば、爆弾を含めれば4種類の敵が出てくるゲームを、それほどプログラムをごちゃごちゃにせずに作れる、ということがわかったかと思います。
ところで、ES2015からクラスが導入されて、Javaなど他の言語と似たような形でオブジェクト指向的なプログラムが書けるようになりましたが、JavaScriptでは、それ以前から、それなりにオブジェクト指向なプログラムを書くことはできました。今となってはそんな古いJavaScript使わないから普通にクラスで書けばいいよ、と言いたいところですが、JavaScriptのクラスは、見かけはともかく中身は、まだ「クラスがなかった時代のJavaScript」の仕組みを引きずっています。今のJavaScriptのクラスは、プロトタイプベースのオブジェクト指向を文法だけクラスっぽく見せかけたもの(こういうのを、文法だけ砂糖をかけて甘くした、という意味で、シンタックスシュガー(syntax sugar)と呼びます)に過ぎません。
JavaScriptのオブジェクト指向を深く知るには、やはりプロトタイプベースの仕組みも知らないわけにはいかないので、このページにまとめました。いつか読んでみてください。
公開日: 2022/10/10