ビームを複数撃てるようにする(配列とオブジェクト)

配列

配列とは

前回まででUFOゲームは動きましたが、遊んでみてまず不満に思えるのは、「前のビームが消えるまで、次のビームが撃てない」ことでしょう。

前回のUFOゲームのプログラム(lesson06_2.html)では、キヤノン砲が撃つビームの座標を、beamXbeamYという一組の変数で保持していました。ビームの座標を保持する変数が一組しかないのですから、ビームが同時にひとつしか存在できないのは当然です。

リスト1: 前回までのビームの座標の持ち方(lesson06_2.htmlから転載)
 43: // ビームのX, Y座標
 44: let beamX;
 45: let beamY;

だからといって、こんなふうにたくさん変数を宣言すればよい、というものでもありません。

let beamX1;
let beamY1;
let beamX2;
let beamY2;
let beamX3;
let beamY3;
    :
    :

この方法でビーム10発分の変数を用意したとして、たとえばビームを表示するところでは、同じ処理を10回並べて書かなければいけません(実際にはビームは10発常に存在しているわけではないので、フラグも10個用意して、if文で囲んだものが10回並びますね)。まあ10発なら不可能ではないかもしれませんが、もっと増えると無理でしょう。

そういう場合に使えるのが、配列(array/アレイ)です。

配列を使ってみる

JavaScriptの場合、配列は、たとえば以下のように使います。

リスト2: lesson07_1.html
  1: <!DOCTYPE html>
  2: <html lang="ja">
  3: <head>
  4: <meta charset="UTF-8">
  5: <title>配列</title>
  6: </head>
  7: <body>
  8: <script>
  9: "use strict";
 10: // 要素数10の配列を作成する
 11: const hoge = new Array(10);
 12: 
 13: // 配列の要素に値を代入する
 14: for (let i = 0; i < 10; i++) {
 15:   hoge[i] = i * 10;
 16: }
 17: // 代入した値を表示する
 18: for (let i = 0; i < 10; i++) {
 19:   console.log("hoge[" + i + "].." + hoge[i]);
 20: }
 21: </script>
 22: </body>
 23: </html>

このプログラムは、まず11行目で、「new Array(10)」により、要素数が10の配列を生成し、それを変数hogeに代入しています。「new」(ニュー)というのはご存じの通り「新しい」という意味なので、「new Array(10)」は、要素数が10の配列を「新しく作る」という意味になります。

配列というのは、変数をずらりと並べたようなものです。こちらで、変数のことを「値を一つだけ書き留めることのできるメモ用紙のようなもの」と説明しましたが、配列は、そのメモ用紙が、ずらりと何枚も(この例では10枚)並んだものと考えることができるでしょう。new Array(10)と書いただけで、一度に10個の変数が宣言できたわけです。

そして、10個作ったそれぞれのメモ用紙(これを配列の要素(element)と呼びます)は、たとえばhoge[5]のように、何番めの要素かを[]の中に指定して読み書きできます。この[]の中の数字のことを添字(そえじ/index)と呼びます。配列のキモは、添字に変数(を含む式)を指定することで、何番めの要素であろうと自由に読み書きできることです。

図1: 配列とは

リスト2では、13行目からのforループで、hoge[0]hoge[9]に、それぞれ0~90の値を代入しています。JavaScript(に限らずたいていのプログラミング言語)では、配列の先頭の要素の添字は1ではなく0です。そして、18行目からのforループで、その代入した値を、コンソールに出力しています。

このHTMLを開くと、ブラウザには何も表示されません。19行目のconsole.log()でコンソールに出力しているので、F12で開発者ツールを起動してコンソールを見ると、以下のようになっています。

図2: lesson07_1.htmlの実行結果

リスト2の15行目で代入しているとおり、配列のそれぞれの要素に、自身の添字の10倍の値が格納されていることがわかります。

この状態の配列を図にすると、下図のようになります。

図3: lesson07_1.htmlの配列のイメージ

JavaScriptの配列は0から始まる

普通、人がものを数えるとき、最初のひとつは「ひとつめ」、次は「ふたつめ」と数えるでしょう。しかし、JavaScriptの配列の添字は0から始まります(これをゼロオリジン(zero origin)と呼ぶこともあります)。

最初のひとつは「ひとつめ」なんだから、1から始まる方がわかりやすくていいのに、と思う人もいるかもしれません。実際、世界最古のプログラミング言語であるFORTRANでは、配列は1から始まっていました。

しかし、たとえば、人間の年齢は(今は)満年齢で数えるので、生まれてすぐは0歳です。「産まれてから今までに何日経過したか」を計算する際、(うるう年を無視すれば)「年齢×365+直近の誕生日からの経過日数」で算出できます。産まれてすぐを1歳と数えていたら、こういう時、「(年齢 - 1)×365+直近の誕生日からの経過日数」というように、わざわざ1を引かなければいけません。学校で「等差数列」を習った人は、等差数列の第n項を「初項 + (n - 1) × 交差」で表現できる、ということを知っているでしょうが、ここで「n - 1」と書かなければいけないのは、初項を第1項、次を第2項、という数え方をしたためです。初項を第0項と数えていれば、いちいち1を引く必要はなかったわけです。

プログラミングにおいても、配列の最初の要素を0と数えた方が便利なケースはたくさんあります。そのため、JavaScriptに限らず最近(といってもここ40年くらい?)のプログラミング言語では、配列はたいていゼロオリジンです。0から始まる配列に慣れてください。

配列は参照経由でアクセスする

図3を見ると、変数hogeからは矢印が出ています。これはつまり、変数hogeに格納されているのは、配列の参照値であり、配列そのものが変数hogeに格納されているわけではない、ということです。参照については、以前こちらでも説明しました。

変数hogeに格納されているのが参照値であることは、hogeの内容を別の変数に代入するとはっきりします。

リスト3: lesson07_2.html
  1: <!DOCTYPE html>
  2: <html lang="ja">
  3: <head>
  4: <meta charset="UTF-8">
  5: <title>配列</title>
  6: </head>
  7: <body>
  8: <script>
  9: "use strict";
 10: // 要素数10の配列を作成する
 11: const hoge = new Array(10);
 12: 
 13: // 配列の要素に値を代入する
 14: for (let i = 0; i < 10; i++) {
 15:   hoge[i] = i * 10;
 16: }
 17: 
 18: // 配列を別の変数に代入する
 19: const piyo = hoge;
 20: piyo[4] = 100;
 21: 
 22: // hogeの内容を表示する
 23: for (let i = 0; i < 10; i++) {
 24:   console.log("hoge[" + i + "].." + hoge[i]);
 25: }
 26: </script>
 27: </body>
 28: </html>

リスト3では、19行目で変数hogeの内容を別の変数piyoに代入しています。

その上で、piyo[4]に100を代入し(20行目)、その後のforループで配列hogeの内容を表示すると、hoge[4]も100になっていることがわかります。

図4: 別の変数経由で配列の内容を変更する

プログラム上は、100を代入しているのはあくまでpiyo[4]に対してであって、hogeに対しては何も触っていないのに、いつの間にかhoge[4]が100になってしまったように見えるかもしれません。これは、hogepiyoが、結局のところ同じ配列を指しているからです。

図5: hogepiyoは同じ配列を指している

変数はあくまで配列への参照値を保持しているだけなので、別の変数も同じ配列を指すことができる、ということをちゃんと意識していないと、「配列の中身が勝手に書き変わる」という解決が難しいバグを起こすことになります。

ところで、リスト2やリスト3で、変数hogepiyoは、constで宣言しています。constは変数の内容を変更しない場合に使うものなのに、ここではhogeの内容を変えているじゃないか、と思った人がいるかもしれません。

しかし、リスト2でもリスト3でも、変数hogepiyoの内容は変更されていません。変更されているのは、hogepiyo指している配列の内容であって、hogepiyoの内容は変わらないままです(ずっと同じ配列を指しています)。なので、constでかまわないわけです。

配列リテラル

リスト2では、「new Array(10)」として要素数10の配列を作りました。この書き方だと、要素数10の配列が作られて、それぞれの要素は空です。

配列の中身が最初からわかっている場合は、配列リテラルという記法が使えます。リテラル(literal)というのは、「(変数とかではなく)値を直接書いたもの」という意味で、たとえば「"abc"」は文字列リテラルですし、「5」は数値リテラルです。

JavaScriptでの配列リテラルは、[ ]の中に、配列の要素の値をカンマで区切って並べます。

const hoge = [1, 2, 3, 4, 5];

以下は、配列リテラルとして作成した配列を、一覧表示するプログラムです。

リスト4: lesson07_3.html
  
  1: <!DOCTYPE html>
  2: <html lang="ja">
  3: <head>
  4: <meta charset="UTF-8">
  5: <title>配列</title>
  6: </head>
  7: <body>
  8: <script>
  9: "use strict";
 10: // 要素数5の配列を、配列リテラルとして作成する
 11: const hoge = [1, 2, 3, 4, 5];
 12: 
 13: // hogeの内容を表示する
 14: for (let i = 0; i < 5; i++) {
 15:   console.log("hoge[" + i + "].." + hoge[i]);
 16: }
 17: </script>
 18: </body>
 19: </html>

実行結果は以下。

図6: 配列リテラルの表示

ところで、こちらでも説明したように、JavaScriptは「変数に型がない」言語です。なので、JavaScriptの変数には、どんな型の値でも代入することができます。同様に、配列の要素も、どんな型でも構いません。ひとつの配列に複数の型を混ぜることもできます。

// 数値型と、文字列型と、論理型を混ぜた配列
const hoge = [1, "abc", true];

これが便利かというと、あまりそんな機会はないと私は思っていますが……

配列の同値性

配列は、「===」演算子で、等しいかどうか判定できます。が、その判定は、「同じ配列かどうか」であって、「配列の中身が等しいか」ではありません。たとえば以下のプログラムでは、「hogeとpiyoは等しくない」と表示されます。変数hogepiyoは、それぞれ別の配列を指しているからです。

const hoge = [1, 2, 3];
const piyo = [1, 2, 3];

if (hoge === piyo) {
  console.log("hogeとpiyoは等しい");
} else {
  console.log("hogeとpiyoは等しくない");
}

以下のように書き換えれば、「hogeとpiyoは等しい」と表示されます。

const hoge = [1, 2, 3];
const piyo = hoge;

if (hoge === piyo) {
  console.log("hogeとpiyoは等しい");
} else {
  console.log("hogeとpiyoは等しくない");
}

===演算子による配列の比較は、配列の内容の比較ではなく、参照値の比較である、と言うこともできます。

配列の操作

UFOゲームのビームの座標を配列で保持して、一度に複数のビームを発射できるようにするには、ただ配列が作れるだけではだめでしょう。

配列に対してできる操作は、たとえばこのページに記載があります。ここではひとまずUFOゲームでビームの座標を保持するのに必要なものだけ紹介します(UFOゲームに限らず、まあ、たいていの用途はこれで足ります)。

ところで、ここまでは、配列の内容を表示するために、わざわざfor文でループを組んで1要素ずつ表示してきました。配列の要素の取得方法を説明する意図もあったのですが、実のところそんなことをしなくても、配列を、たとえば文字列と+演算子でつなぐ等して文字列に変換すれば、各要素の値がカンマ区切りになった文字列になります。今後はこちらを使うことにします※1

const hoge = [1, 2, 3];
console.log("hoge.." + hoge);
// 「hoge..1,2,3」と表示される
配列の要素数を取得する(lengthプロパティ)
配列の要素数は、length(レングス)プロパティで取得できます。
const hoge = [1, 2, 3];
console.log("hoge.length.." + hoge.length);
// 「hoge.length..3」と表示される
リスト2とか3では、配列の各要素について代入したり表示したりする際、
for (let i = 0; i < 10; i++) {
のように、10という要素数をべた書きしてループさせていましたが、lengthプロパティを使えばこう書けます。
for (let i = 0; i < hoge.length; i++) {
この書き方は、配列を添字でループさせるときの定石と言ってよいでしょう。 ところで、lengthプロパティには代入することもできます。現在の要素数よりより小さな値を代入すれば配列が縮小されますし、大きな値を代入すれば拡張されます(拡張される、というのがどういう意味かは難しいです。次の補足を参照のこと)。
配列の末尾に要素を追加する(pushメソッド)
UFOゲームのビームの座標を配列で管理するなら、新たにビームを撃ったら、そのビームの座標を配列に追加しなければいけません。
配列の末尾に要素を追加するには、push()(プッシュ)メソッドを使用します。配列の末尾に要素を「押し込む(プッシュする)」イメージです。
const hoge = [1, 2, 3];
hoge.push(4); // 配列の末尾に4を追加
console.log("hoge.." + hoge);
// 「hoge..1,2,3,4」と表示される
配列の要素を削除する(spliceメソッド)
ビームは、画面の上端に達したら消えますから、その時は配列から削除してやらなければなりません※2
配列から要素を削除するには、splice()(スプライス)メソッドを使用します。splice()メソッドは引数をふたつ取り、ひとつめの引数で削除する最初の要素の添字を、ふたつ目の引数で削除する要素の数を指定します。
let hoge = [1, 2, 3, 4, 5, 6, 7, 8];
hoge.splice(2, 1); // 添字が2の要素を削除する
console.log("hoge.." + hoge);
// 「hoge..1,2,4,5,6,7,8」と表示される(3が消えている)

hoge = [1, 2, 3, 4, 5, 6, 7, 8]; // hogeを最初に戻す
hoge.splice(2, 3); // 添字が2の要素を起点に、3つの要素を削除する
console.log("hoge.." + hoge);
// 「hoge..1,2,6,7,8」と表示される(3~5が消えている)
spliceというのは、英語では、縄とかテープとかフィルムとかを「つなぎ合わせる」といったような意味です。配列の真ん中を抜き取って両側をつなぎ合わせるということでしょう。

JavaScriptの配列の怪

たとえば以下のように要素数10個の配列を作り、そのサイズを超えたところ(たとえば[20])にいきなり代入したらどうなるでしょうか。

const hoge = Array(10);
hoge[20] = 5; // 要素数10の配列の[20]にいきなり代入

「そりゃエラーになるんじゃないの?」と思うかもしれません。他の言語の経験がある人ならなおさらでしょう。

ところがどっこい、JavaScriptでは、これはエラーになりません。そして、hoge[20]に代入した後でhoge.lengthの値を見ると、21になっています。

「なるほど、配列の範囲を超えたところに代入すると、自動でそのサイズまで拡張されるんだ」と思うかもしれません。しかし、そうでもない、というのがさすがは変態言語JavaScriptです。

配列hogeについて、Object.keys(hoge)のように書くと、その配列の「今有効な添字」の一覧が配列で取得できます。「Objectって何だ?」とか「keysって何だ?」といった疑問はいったん忘れてください。たとえば要素数が3つの配列についてこれを適用すると、「0,1,2」という配列が取得できます。

const hoge = [10, 20, 30];
console.log("keys.." + Object.keys(hoge));
// 「keys..0,1,2」と表示される

上記のように、普通に作った要素数3つの配列の添字は、0, 1, 2だからですね(「これが何の役に立つんだ?」といった疑問もちょっと脇に置いてください)。

さて、次は、この配列の[10]にいきなり代入してみます。

hoge[10] = 10;
console.log("keys.." + Object.keys(hoge));
// 「keys..0,1,2,10」と表示される

範囲外のところに代入したとき、配列が「自動で拡張される」のなら、結果は「0,1,2,3,4,5,6,7,8,9,10」でなければならないと思いますが、実際は「0,1,2,10」になっています。つまり、JavaScriptの配列の添字は、途中を飛ばすことができるのです。

また、このページの最初のサンプルのように「new Array(10)」で確保した配列について同じことを試してみると、なんと結果は空です。どこかの要素に代入して初めて、その添字がkeysに登場します。

const hoge = new Array(10);
console.log("keys.." + Object.keys(hoge));
// 「keys..」と表示される
hoge[3] = 5;
console.log("keys.." + Object.keys(hoge));
// 「keys..3」と表示される

じゃあ最初にnew Array(10)で指定した10は何だったんだ、と言えば、いちおうこの配列のlengthを取得すると10が取得できます。ではlengthは何を意味するんだ、keysの数でもkeysの最大値+1でもないなら何物だ、と思えることでしょう。たとえば、上記の状態のhogeに対してpush()すると、11番目の要素として追加されるので、そこではlengthを使っているわけですが。

hoge.push(3);
console.log("keys.." + Object.keys(hoge));
// 「keys..3,10」と表示される
console.log("hoge.." + hoge);
// 「hoge..,,,5,,,,,,,3」と表示される

ヘンな言語だと思うでしょう? 私もそう思います。

――JavaScriptの配列がこんなことになっているのは、要するに「配列もオブジェクト(連想配列)に過ぎない」という特徴によるものです。こちらの補足で後述します。

ビームを複数撃てるようにする

では話題をUFOゲームに戻します。

上でも書きましたが、前回までは、ビームのX,Y座標をbeamXbeamYというふたつの変数で保持していました。この変数が一組しかないので、一度に一発のビームしか撃てなかったわけです。

リスト5: 前回までのビームの座標の持ち方(lesson06_2.htmlから転載/再掲)
 43: // ビームのX, Y座標
 44: let beamX;
 45: let beamY;

これをそれぞれ配列にして、同じ添字の要素一組で、ひとつのビームのX座標、Y座標を保持するようにすれば、複数のビームの座標を保持できるでしょう。

図7: 配列でビームの座標を保持する

この場合、ふたつの配列beamXbeamYの長さ(lengthプロパティ)は常に等しくなります。というか、等しくなるようにプログラマであるあなたが制御しなければいけません。

ビームを発射したら、beamXbeamYの両方にX, Y座標をpush()して、ビームが画面上端まで行って消える時には、両方からsplice()を使って削除します。初期状態では、ビームは発射されていませんから、最初は空の配列リテラルで初期化してやります。

// ビームのX, Y座標
let beamX = [];
let beamY = [];

この方針で修正したプログラムが、リスト6です。前回のプログラム(lesson06_2.htmlからの変更点を赤字にしています。

リスト6: lesson07_ufo1.html
  1: <!DOCTYPE html>
  2: <html lang="ja">
  3: <head>
  4: <meta charset="UTF-8">
  5: <title>UFOゲーム</title>
  6: </head>
  7: <body>
  8: <canvas id="canvas" width="800px" height="600px" style="background-color:black;"></canvas>
  9: <script>
 10: "use strict";
 11: // UFOの画像読み込み
 12: const ufoImage = new Image();
 13: ufoImage.src = "./ufo.png";
 14: // UFOの画像読み込み(爆発2パターン)
 15: const ufoExplosionImage1 = new Image();
 16: ufoExplosionImage1.src = "./ufo_explosion1.png";
 17: const ufoExplosionImage2 = new Image();
 18: ufoExplosionImage2.src = "./ufo_explosion2.png";
 19: 
 20: // キャノン砲の画像読み込み
 21: const cannonImage = new Image();
 22: cannonImage.src = "./cannon.png";
 23: 
 24: const canvas = document.getElementById("canvas");
 25: const context = canvas.getContext("2d");
 26: 
 27: // UFOの初期位置は、とりあえず左上とする
 28: let ufoX = 0;
 29: let ufoY = 0;
 30: // 「目標点」の初期位置も、左上とする
 31: let ufoTargetX = 0;
 32: let ufoTargetY = 0;
 33: 
 34: // キャノン砲の初期位置は、真ん中あたりとする
 35: let cannonX = 375;
 36: 
 37: // 左右のカーソルキーが押されているかどうかを保持するフラグ
 38: let leftKeyPressed = false;
 39: let rightKeyPressed = false;
 40: 
 41: // ビームのX, Y座標
 42: let beamX = [];
 43: let beamY = [];
 44: 
 45: document.onkeydown = keyDown;
 46: document.onkeyup = keyUp;
 47: 
 48: // UFOが爆発するアニメーションの繰り返し回数
 49: let ufoExplosionCounter;
 50: 
 51: mainLoop();
 52: 
 53: function mainLoop() {
 54:   // 画面をクリアする
 55:   context.clearRect(0, 0, 800, 600);
 56:   // UFOを描く
 57:   context.drawImage(ufoImage, ufoX, ufoY);
 58:   // キャノン砲を描く
 59:   context.drawImage(cannonImage, cannonX, 570);
 60:   // ビームが当たったかどうかを判定する
 61:   for (let i = 0; i < beamX.length; i++) {
 62:     if (beamX[i] > ufoX && beamX[i] < ufoX + 79
 63:         && beamY[i] > ufoY && beamY[i] < ufoY + 31) {
 64:       ufoExplosionCounter = 0;
 65:       shootDown();
 66:       return;
 67:     }
 68:   }
 69: 
 70:   // ビームを描く
 71:   context.strokeStyle = "yellow";
 72:   for (let i = 0; i < beamX.length; i++) {
 73:     context.beginPath();
 74:     context.moveTo(beamX[i], beamY[i]);
 75:     context.lineTo(beamX[i], beamY[i] + 20);
 76:     context.stroke();
 77:   }
 78: 
 79:   // UFOを動かす
 80:   if (ufoX === ufoTargetX && ufoY === ufoTargetY) {
 81:     // UFOの位置と目標点が一致しているので、新たな目標点を設定
 82:     ufoTargetX = Math.floor(Math.random() * 73) * 10;
 83:     ufoTargetY = Math.floor(Math.random() * 51) * 10;
 84:   }
 85:   // UFOを目標点に近づける
 86:   if (ufoX < ufoTargetX) {
 87:     ufoX += 10;
 88:   } else if (ufoX > ufoTargetX) {
 89:     ufoX -= 10;
 90:   }
 91:   if (ufoY < ufoTargetY) {
 92:     ufoY += 10;
 93:   } else if (ufoY > ufoTargetY) {
 94:     ufoY -= 10;
 95:   }
 96: 
 97:   // キャノン砲を動かす
 98:   if (leftKeyPressed && cannonX > 0) {
 99:     cannonX -= 10;
100:   } else if (rightKeyPressed && cannonX < 750) {
101:     cannonX += 10;
102:   }
103:   // (発射中なら)ビームを動かす
104:   for (let i = 0; i < beamX.length; ) {
105:     if (beamY[i] > 0) {
106:       beamY[i] -= 10;
107:       i++;
108:     } else {
109:       beamX.splice(i, 1);
110:       beamY.splice(i, 1);
111:     }
112:   }
113: 
114:   setTimeout(mainLoop, 20);
115: }
116: 
117: function keyDown(e) {
118:   if (e.code === "ArrowLeft") {
119:     leftKeyPressed = true; 
120:   } else if (e.code === "ArrowRight") {
121:     rightKeyPressed = true;
122:   } else if (e.code === "Space" && beamX.length < 3) {
123:     beamX.push(cannonX + 25);
124:     beamY.push(550);
125:   }
126: }
127: 
128: function keyUp(e) {
129:   if (e.code === "ArrowLeft") {
130:     leftKeyPressed = false;
131:   } else if (e.code === "ArrowRight") {
132:     rightKeyPressed = false;
133:   }
134: }
135: 
136: // UFOを撃墜したときに呼び出される関数
137: function shootDown() {
138:   if (ufoExplosionCounter % 2 === 0) {
139:     context.drawImage(ufoExplosionImage1, ufoX, ufoY);
140:   } else {
141:     context.drawImage(ufoExplosionImage2, ufoX, ufoY);
142:   }
143:   ufoExplosionCounter++;
144:   if (ufoExplosionCounter <= 30) {
145:     setTimeout(shootDown, 100);
146:   } else {
147:     ufoX = 0;
148:     ufoY = 0;
149:     ufoTargetX = 0;
150:     ufoTargetY = 0;
151:     beamX = [];
152:     beamY = [];
153:     mainLoop();
154:   }
155: }
156: </script>
157: </body>
158: </html>

それでは、変更箇所を順に見ていきます。

まずは41~43行目、上でも説明しましたが、ここでビームのX座標、Y座標を保持するふたつの配列を宣言し、まずは空の配列リテラルを代入することで初期化しておきます。

 41: // ビームのX, Y座標
 42: let beamX = [];
 43: let beamY = [];

次は60~68行目の、ビームが当たったかどうかを判定しているところです。for文で、配列の要素を順に回して、現在発射中のビームについて、UFOに当たっているかどうかを順に判定しています。こうやって、ループで各要素を参照できるのが、配列の強みです。

JavaScriptの配列は0から始まり、最後の要素の添字は要素の個数(length)よりひとつ小さいので、for文でループする際は、ここでの例のように、「for (let i = 0; i < beamX.length; i++) {」という形になるのが定石です。

ここで、配列はbeamXbeamYのふたつありますが、このふたつの配列の長さは常に等しいはずなので、beamX.lengthの回数だけループするようにしています。もちろんここはbeamY.lengthの回数だけループさせてもかまいません。どうせ同じ数(のはず)だからです。

 60:   // ビームが当たったかどうかを判定する
 61:   for (let i = 0; i < beamX.length; i++) {
 62:     if (beamX[i] > ufoX && beamX[i] < ufoX + 79
 63:         && beamY[i] > ufoY && beamY[i] < ufoY + 31) {
 64:       ufoExplosionCounter = 0;
 65:       shootDown();
 66:       return;
 67:     }
 68:   }

ちなみに前回までのソースでは、この部分は以下のようになっていました。

前回はbeamFlagというフラグがありましたが、今回は、配列が空の状態のときはループを一度も回らないので、beamFlagは不要です。

(前回のソース)
 71:   // ビームが当たったかどうかを判定する
 72:   if (beamFlag) {
 73:     if (beamX > ufoX && beamX < ufoX + 79
 74:         && beamY > ufoY && beamY < ufoY + 31) {
 75:       ufoExplosionCounter = 0;
 76:       shootDown();
 77:       return;
 78:     }
 79:   }

その他、ビームを表示するところや、ビームを動かすところも、同様にループで処理します。

70~77行目が、ビームを表示するところ、

 70:   // ビームを描く
 71:   context.strokeStyle = "yellow";
 72:   context.beginPath();
 73:   for (let i = 0; i < beamX.length; i++) {
 74:     context.moveTo(beamX[i], beamY[i]);
 75:     context.lineTo(beamX[i], beamY[i] + 20);
 76:     context.stroke();
 77:   }

103~112行目が、ビームを動かすところです。

103:   // (発射中なら)ビームを動かす
104:   for (let i = 0; i < beamX.length; ) {
105:     if (beamY[i] > 0) {
106:       beamY[i] -= 10;
107:       i++;
108:     } else {
109:       beamX.splice(i, 1);
110:       beamY.splice(i, 1);
111:     }
112:   }

ビームを動かすところでは、ビームが上端に達したら、そのビームを配列から削除しなければいけません。

spliceメソッドで配列から要素を削除すると、削除により空いた「隙間」には、後ろの要素が詰められます。よって、for文でループを回しながらループカウンタのiがたとえば3のとき、「あ、この要素を削除しなくちゃ」ということがわかったとして、[3]の要素を削除した後のループの次の回では、やはりiは3を指していなければいけません。次に削除の候補となるのは、後ろから詰まってきた[3]の要素だからです。

図8: for文で配列から要素を削除する

配列を回すfor文といえば、「for (let i = 0; i < beamX.length; i++) {」のように書くのが定石ですが、配列の要素を削除した時にはi++を行ってはいけません。そこで、上のプログラムの104行目では、for文の第3式は書かずに、削除しなかった場合のみ、107行目でi++を実行しています。

次は122~125行目、スペースキーを押してビームを発射するところです。配列beamXbeamYそれぞれに、新しいビームの座標をpush()しています。

122:   } else if (e.code === "Space" && beamX.length < 3) {
123:     beamX.push(cannonX + 25);
124:     beamY.push(550);
125:   }

上のプログラムを見ればわかるとおり、このプログラムでは、一度に発射できるビームの数の上限を3にしています。10でも20でもよいですし、何なら制限をかけなくてもプログラムとしては問題ないですが、あまり連射できるとゲームとして簡単になりすぎるので3としました。このあたりは適当に調整してください。

最後は、UFOを撃墜して爆発のアニメーションを行った後、初期化してmainLoop()に戻るところです(151~152行目)。

151:     beamX = [];
152:     beamY = [];

ビームの座標を保持する配列も初期化し、空にしています。

このページでは、たとえばリスト3のlesson07_2.htmlでは、配列のhogeとかpiyoconstで宣言していました。にもかかわらずリスト6ではbeamXbeamYletで宣言しています。その理由は、ここでbeamXbeamYに対する代入を行いたかったためです。

もしconstにしたいのであれば、空の配列リテラル([])を代入するのではなく、beamX.lengthに0を代入することで配列を空にしてやる、という方法が使えます。

空配列を代入した場合は、beamXの参照先が変わっている(変数beamXに格納されている参照値が書き換えられている)ためconstにはできませんが、lengthに0を代入して元の配列の内容をクリアするのであれば、beamXの参照先は変わりません。

図9: 配列をクリアするふたつの方法

ところで、あらためて図9を見ると、beamXに空配列を代入した後、もともと指していた配列はどうなるのか気になる人がいるかもしれません。この配列は、もはやどの変数からも指されていないので、内容を参照することはできません。こんなのをそのまま放置したらメモリがもったいなのでは――という懸念はもっともですが、JavaScriptではこういうものはガベージコレクション(garbage collection)という機能により自動で破棄してくれるので、プログラマが気にする必要はありません。

ここをクリックすると、このバージョンのUFOゲームが別タブで開きます。

――さて、ビームが複数撃てるようになりました。

ここまでのプログラムで使ってきた方法は完全に正しいですし、これで立派に動きます。

ただし、現状のプログラムは、私から見れば、いくつか気に入らないところがあります。以下のような点です。

  1. ビームのX座標とY座標を、別々の配列で管理している
  2. プログラムの中に、79だの51だの、謎の数字がばらまかれている
  3. スペースキーを押しっぱなしにすると、途中からビームを連射してしまう。

以下で、順に解決していくことにします。

オブジェクト

オブジェクトとは

まず、上で「気に入らないところ」として上げたひとつめ、「ビームのX座標とY座標を、別々の配列で管理している」という点について考えます。確かにリスト6では、図6で示したように、ふたつの配列でビームの座標を保持しています。

図10: ふたつの配列でビームの座標を保持(図6の再掲)

確かにこれで複数のビームの座標を保持できるのですが、やりたいことがそれなら、ひとつのビームのX座標、Y座標をひとかたまりとして、そのかたまりをひとつの配列で保持するほうが自然ではないでしょうか(図10)※3

図11: ひとつの配列でビームの座標を保持

※と、書いておいて何ですが、この図10にぴったり合うようなデータの持ち方はJavaScriptではできません。後述します。


元の、配列をふたつ使う方法だと、以下のような問題があります。

図10のように、1本の配列で複数のビームを保持し、その配列の要素ひとつでひとつのビームの情報(X座標、Y座標、あとは追加するなら威力とか)を保持するようにすれば、こういった問題は解決するわけです。

そして、これを実現するためには、X座標とY座標をまとめて保持できるモノ、図10で配列のひとつの要素となっている、この箱を表現するモノが必要です。JavaScriptでは、この用途にオブジェクト(object)を使います。

図12: オブジェクト

オブジェクトを使ってみる

それでは、オブジェクトを実際に使ったサンプルプログラムを書いてみます。

リスト7: lesson07_4.html
  1: <!DOCTYPE html>
  2: <html lang="ja">
  3: <head>
  4: <meta charset="UTF-8">
  5: <title>オブジェクト</title>
  6: </head>
  7: <body>
  8: <script>
  9: "use strict";
 10: // 新しいオブジェクトを作る
 11: const hoge = new Object();
 12: 
 13: // オブジェクトのプロパティに代入する
 14: hoge.x = 10;
 15: hoge.y = 20.5;
 16: hoge.str = "abc";
 17: hoge.arr = [1, 2, 3];
 18: 
 19: // hogeの内容を表示する
 20: console.log("hoge.x.." + hoge.x);
 21: console.log("hoge.y.." + hoge.y);
 22: console.log("hoge.str.." + hoge.str);
 23: console.log("hoge.arr.." + hoge.arr);
 24: </script>
 25: </body>
 26: </html>

まず11行目。配列を「new Array(10)」として作ったように、オブジェクトは「new Object()」として作ります。この状態では、オブジェクトは空です。

そして、14~17行目で、その空のオブジェクトのプロパティ(property)に代入を行っています。

この入門では、最初にここで「プロパティというのはオブジェクトに属する変数のこと」と雑に説明しましたが、これがそのプロパティです。JavaScriptでは、このように、オブジェクトのプロパティは、代入することで自動的に作られます。リスト7ではx, y, str, arrという名前のプロパティを作っています(この名前は私が勝手に決めたもので、JavaScript的に意味があるわけではありません。strはstringの略、arrはarrayの略です)。

オブジェクトのプロパティには、どんな型の値でも代入できます。リスト7では、20~21行目では数値型を、22行目では文字列型を、23行目では配列をプロパティに代入しています。

20~23行目で、その内容を表示しています。

図13: lesson07_4.htmlの実行結果

プロパティに代入した値が、ちゃんと参照できていることがわかります。

この状態を図にすると、図13のようになります。

図14: lesson07_4.htmlの実行結果の図

オブジェクトも参照経由でアクセスする

図12では、変数hogeからは矢印が出ていて、それが、xとかyとかの値を格納した箱を指しています。この箱がオブジェクトであり、変数hogeが保持しているのは、オブジェクトを指す参照値です。このあたりは配列と同様です。

それを確認するプログラムが、リスト8です。

リスト8: lesson07_5.html
  1: <!DOCTYPE html>
  2: <html lang="ja">
  3: <head>
  4: <meta charset="UTF-8">
  5: <title>オブジェクト</title>
  6: </head>
  7: <body>
  8: <script>
  9: "use strict";
 10: // 新しいオブジェクトを作る
 11: const hoge = new Object();
 12: 
 13: // オブジェクトのプロパティに代入する
 14: hoge.x = 10;
 15: hoge.y = 20.5;
 16: 
 17: const piyo = hoge;
 18: 
 19: // piyoの内容を表示する
 20: console.log("piyo.x.." + piyo.x);
 21: console.log("piyo.y.." + piyo.y);
 22: 
 23: // piyoの内容を変更する
 24: piyo.x = 100;
 25: 
 26: // hogeの内容を表示する
 27: console.log("hoge.x.." + hoge.x);
 28: console.log("hoge.y.." + hoge.y);
 29: 
 30: 
 31: </script>
 32: </body>
 33: </html>

実行結果は以下。17行目でpiyohogeを代入していますが、そうすることで、hogeのプロパティに設定した値がpiyoのプロパティとしても表示できていますし、piyo.xを変更するとhoge.xも書き変わっていることがわかります。

図15: lesson07_5.htmlの実行結果

これは、図16のように、hogepiyoが同じオブジェクトを指しているからです。

図16: 複数の変数が同じオブジェクトを指している

――ところで、今回、ビームの座標の持ち方について、2本の配列で持つのではなく、オブジェクトを使用するようにしたのは、以下のような形にしたいからでした。

図17: 本当は、ビームの座標をこういう形で保持したかった(図10の再掲)

しかし、JavaScriptでは、オブジェクトは参照でしか保持できないわけですから、実際のデータの持ち方は、図17のようになります。

図18: JavaScriptでは、実際にはこういう持ち方になる

これはもう、JavaScriptはこういうものだと思っていただくしかありません。こうやってオブジェクトを参照で保持すると、図16の例のように「座標をコピーしたつもりが、実は同じオブジェクトを指していた」ということが起きて、結構タチの悪いバグのもとになるのですが……

オブジェクトリテラル

ここまでの知識で、UFOゲームのビームの座標をオブジェクトで保持するには十分なのですが、ついでなのでオブジェクト関連の書き方を一緒に覚えてしまいましょう。

配列と同じように、オブジェクトも、内容が最初からわかっているときはオブジェクトリテラルの記法が使えます。配列リテラルは[ ]の中に要素をカンマで区切って並べましたが、オブジェクトリテラルは、「{ }」の中に、プロパティ名と値を「:」でつなげたものを、カンマで区切って並べます。

const hoge = {
  x: 10,
  y: 20.5
};

空のオブジェクトが欲しいとき、「hoge = new Object();」と書くほかに、オブジェクトリテラルで「hoge = {};」と書くこともできます。

ところで、オブジェクトリテラルは、プロパティ名部分をダブルクォートまたはシングルクォートで囲む書き方もあります※4。ダブルクォートで囲まない場合、プロパティ名は、変数と同じような命名規則に従うので、たとえば「123#$%」のような名前にはできませんが、ダブルクォートなりシングルクォートなりで囲めば可能です。

const hoge = {
  "x": 10, // プロパティ名をダブルクォートで囲む
  "123'#$%": 20.5 // こんな名前のプロパティも作れる
};

const piyo = {
  'x': 10, // プロパティ名をシングルクォートで囲む
  '123"#$%': 20.5 // こんな名前のプロパティも作れる
};

123'#$%」なんてプロパティを作ったとして、それをどうやって参照すればいいんだ? まさか「hoge.123'#$%」とは書けないだろう」と思った人は鋭いです。これを参照するには、次の「ブラケット表記法」を使います。

ブラケット表記法

たとえばhogeのプロパティhoge.xは、hoge.xと書くほかに、hoge["x"]と書くこともできます。この書き方をブラケット表記法と呼びます。逆に、今までのhoge.xという書き方は、ドット表記法と呼びます。

ブラケット表記法で書いてもドット表記法で書いても、参照されるプロパティは同じものです。

const hoge = {};
hoge.x = 10;
console.log('hoge["x"]..' + hoge["x"]);
// 「hoge["x"]..10」と表示される
hoge["y"] = 20;
console.log("hoge.y.." + hoge.y);
// 「hoge.y..20」と表示される

ブラケット表記法は、ドット表記法と比べてちょっと文字数がかさみますが、以下の利点があります。

ブラケット([])の中に式を入れてプロパティを選ぶ、となると、これは、[]の中に入れるのが数値か文字列かだけの違いで、配列のようなものではないか、と思うことでしょう。このように添字の代わりに文字列が使える配列のことを、JavaScriptに限らずプログラミング一般の用語で、連想配列(associative array)と呼びます。

連想配列は、キー(key)の文字列と紐づけて値(value)を格納することができ、また、キーを与えれば、そのキーに紐づけられた値を取り出すことができます。

JavaScriptの配列はオブジェクトだ

JavaScriptの配列は、オブジェクトの一種です。

なので以下のように、配列として作ったオブジェクトにも、普通にプロパティを足せます。

const hoge = [1, 2, 3];
hoge.x = 10;

このオブジェクトを、以下のようにconsole.log()で表示すると※5

console.log(hoge);

Edgeなら、いったん「(3) [1, 2, 3], x:10」のように表示されます。これを見ると、配列のすみっこにxというプロパティが足された、という感じがするかもしれませんが、この左の三角をクリックして展開すると、こう展開されます。

図19: 配列にプロパティを足したオブジェクトを見る

配列の添字である1, 2, 3と、プロパティxおよびlengthが、同列に並んでいるように見えます。

そういえば、上の補足「JavaScriptの配列の怪」にて、Object.keys()というメソッドを使って、配列の「今有効な添字」の一覧を取得しました。同じことをこのhogeに対してもやってみましょう。

console.log("keys.." + Object.keys(hoge));
// 「keys..0,1,2,x」と表示される

やっぱり、配列の添字として使える数である0, 1, 2とxが、同列に並んでいます。

――結局のところ、JavaScriptでは、数値を添字とするいわゆる普通の配列も、その実体は連想配列なのです。上で使ったObject.keys()というメソッドは、そのオブジェクト(連想配列)のキーの一覧を取得するメソッドです。Object.keys()について、補足「JavaScriptの配列の怪」では『その配列の「今有効な添字」』を取得するメソッドだと書きましたが、読者はおそらく変な説明だなあと思ったことでしょう。連想配列のキーの一覧(つまりオブジェクトのプロパティの一覧)を取得するメソッド、ということであれば、その名前にも用途にも納得がいくのではないでしょうか。

そして、JavaScriptでは、連想配列のキーは基本的に文字列です(ES2015からはシンボル(Symbol)も使うことができるようになりましたが、ここでは扱いません)。数値をキーにした場合でも、数値は内部的に文字列に変換されます。つまり、普通の配列で、「hoge[0] = 5;」のように代入した場合でも、それは「hoge["0"] = 5;」と書くのと同じことであり、試してみればわかりますがhoge[0]でもhoge["0"]でも同じものが参照されます。

const hoge = [1, 2, 3];
hoge["0"] = 5;
console.log("hoge[0].." + hoge[0]);
// 「hoge[0]..5」と表示される
console.log('hoge["0"]..' + hoge["0"]);
// 「hoge["0"]..5」と表示される

こうして見てみると、「JavaScriptの配列の怪」で挙げた「要素数3の配列の[10]にいきなり代入すると、途中を飛ばした配列ができる」というのも、当たり前のように思えます。単なる連想配列だからです。配列は、要素の追加削除で自動で値が変わるlengthという変なプロパティを持った変なオブジェクトにすぎません※6

謎の数字を排除する

次は、前に気に入らないところとして挙げた、ふたつめの点、「プログラムの中に、79だの51だの、謎の数字がばらまかれている」という点について考えます。

リスト6のUFOゲームのプログラムでは、いくつか「謎の数字」が埋め込まれています。たとえばここ。

 34: // キャノン砲の初期位置は、真ん中あたりとする
 35: let cannonX = 375;

これは、canvasの幅が800ピクセルで、キャノン砲の幅が50ピクセルで、キャノン砲を真ん中に表示したいので、800の半分の400から、50の半分の25を引いた値、375を指定しているわけです(図19)。

図20: キャノン砲の初期位置のX座標の算出

まあ、難しい計算ではないと思いますが、プログラムの中にいきなり「375」という数が出てきたのでは、後からこのプログラムを読む他人にはわけがわからないでしょう。「どうせこのプログラムは自分しか読まないよ」と思っても前にも書いた通り未来の自分なんて他人と同じです。どうせ忘れてしまうからです。

また、こういう謎の数字(マジックナンバー(magic number)と呼んだりします)をプログラム中にばらまくと、後でその値を修正したい時に困ります。canvasを900に広くしようとか、キャノン砲のサイズを変えようとした場合です。「エディタの置換機能で修正すればいいじゃん」と思うかもしれませんが、canvasの幅とは別の意味の「800」がないとも限りませんし、800を元に算出された「375」のような値は置換機能では見つけられないでしょう。

ではどうすべきなのか。まず、canvasの幅や高さについては、「800」とか「600」といった数値を直接書かなくても、幅ならcanvas.width、高さはcanvas.heightというプロパティで取得できます。常にこれを使うようにすれば、800とか600とかの値を直接書く場所は、HTML中でcanvas要素を書いているところだけ、つまりリスト6で言えば8行目のところだけですみますし、canvasのサイズを変えるときはこの1か所だけを変えればよいということになります。

  8: <canvas id="canvas" width="800px" height="600px" style="background-color:black;"></canvas>

上の例の「375」についていえば、元にしている値はcanvasの幅とキャノン砲の幅です。ではキャノン砲の幅はどうすればよいかというと――カンのいい人は「キャノン砲の画像はcannonImageで保持しているのだから、cannonImage.widthで取得できるのでは」と思うかもしれません。実のところそれはまったく正しいのですが、今回それを使いたい、リスト6の35行目の位置では、うまくいきません。

 34: // キャノン砲の初期位置は、真ん中あたりとする
 35: let cannonX = 375;

試してみるとわかりますが、cannonImage.widthを参照しても、値は0になっています。これは、ここで説明したとおり、画像の読み込みはこの時点ではまだ終わっていないためです。人間の目には「ばれない」からいいやと手を抜いたのが、ここへきて仇となりました

正しくは、画像のロードを終えてから続きの処理をする、というようにプログラムを直すべきなのでしょう。そうすれば、キャノン砲の幅は画像ファイルだけが保持していることになるので、ペイントでキャノン砲の絵を書き直して大きさを変えても、プログラムを一切直す必要はありません。でもそれは結構面倒なのであきらめて、キャノン砲の幅は、定数(constant)で定義することにします。つまり、以下のように、const指定した変数にキャノン砲の幅を代入しておくのです。

const CANNON_WIDTH = 50;

これなら、意味が分かりやすいですし、ペイントでキャノン砲のサイズを変えた場合は、(画像のほかには)この1か所だけを直せばすみます。

こういう用途でconst変数を宣言するときは、上の例のように、大文字をアンダースコアでつないだ名前(アッパースネークケース)にするのが慣習です。元はCの慣習で、JavaScriptはJavaを経由してそれを引き継ぎました。

CANNON_WIDTHを定義したことで、キャノン砲の初期位置は、以下のように書けます。

// キャノン砲の初期位置は、真ん中あたりとする
let cannonX = (canvas.width / 2) - (CANNON_WIDTH / 2);

このままでもよいですが、「/ 2」が2回出てくるので式変形するとちょっと短くできます。

// キャノン砲の初期位置は、真ん中あたりとする
let cannonX = (canvas.width - CANNON_WIDTH) / 2;

ほかの値もどんどん定数にしていきましょう。まずはUFOの幅と高さ、およびUFOの移動速度です。今まで、UFOの移動時には、ufoXufoYに10を足したり引いたりしていました。この10が移動速度です。

const UFO_WIDTH = 79;
const UFO_HEIGHT = 31;
const UFO_SPEED = 10;

リスト6では、UFOが次に移動する「目標点」を以下のように算出しています。

 82:     ufoTargetX = Math.floor(Math.random() * 73) * 10;
 83:     ufoTargetY = Math.floor(Math.random() * 51) * 10;

この算出方法については、こちらで説明しましたが、canvasの幅が800ピクセルとか、UFOの幅が79ピクセルとかのマジックナンバーを使わずに再説明してみましょう。

図21: UFOの目標点の算出

  1. ① UFOは、初期位置が(0, 0)で、そこからUFO_SPEED単位で動きます。ということは、目標点も、UFO_SPEEDの倍数になっていなければいけません。図21で言えば、水色の格子の交点のどれかになります。
  2. ② JavaScriptのdrawImage()関数には、画像の左上の座標を渡しますから、UFOのX座標(変数でいえばufoX)はUFOの左端の座標です。UFOが画面をはみ出さずに動くには、目標点のX座標(変数でいえばufoTargetX)は、UFOの幅の分だけ空けておかなければいけません。
  3. ③ canvasの幅からUFOの幅を引いた幅の中に、UFO_SPEED間隔の格子の箱が、横に何個入るか算出すれば、ufoTargetXの取りうる値の数がわかります。
    これを算出するには、canvasの幅からUFOの幅を引いた幅を、格子の幅(UFO_SPEED)で割ってやります。割り算なので端数が出ますが、Math.floor()で切り捨てればよいでしょう。こうして出た数に1を足したものが、ufoTargetXの取りうる値の数です。ここで1を足すのは、0の分を含めれば、格子の交点の数は格子の箱の数よりひとつ多いからです。
    リスト6では、canvasの幅が800、UFOの幅が79だったので、引き算して721、これをUFOの速度10で割って72、これに1を加えた73が、82行目に登場しているわけです。
  4. ④ 縦方向については、UFOがキャノン砲に近づきすぎないように、下を100ピクセルほど空けています。この100にはたいした根拠はありません。
  5. ⑤ 縦方向についても、③と同様に、canvasの幅から100を引いた高さの中に、格子の箱がいくつ入るかを算出します。それに1を加えたものが、ufoTargetYの取りうる値の数です。

上記の③、⑤の計算をプログラムにすると、以下のようになります。

// UFOの目標点のX座標、Y座標の最大値。
// UFOはUFO_SPPEDドットずつ動くので、 実際にはこれにUFO_SPEEDを掛ける。
const UFO_TARGET_X_MAX = Math.floor((canvas.width - UFO_WIDTH) / UFO_SPEED) + 1;
const UFO_TARGET_Y_MAX = Math.floor((canvas.height - 100) / UFO_SPEED) + 1;

計算結果をUFO_TARGET_X_MAXUFO_TARGET_Y_MAXという名前の変数に格納しています。正直、この変数名(定数名)はあまりよいとは言えません。UFOの目標点のX座標、Y座標の最大値は、ここで算出した値にUFO_SPEEDをかけたものになるからです。よい名前が思いつかなかったので、ここでは、コメントで説明して逃げることにしました。

このUFO_TARGET_X_MAXUFO_TARGET_Y_MAXを使えば、リスト6の目標点を算出しているところ(82~83行目)は、以下のように書けます。

ufoTargetX = Math.floor(Math.random() * (UFO_TARGET_X_MAX)) * UFO_SPEED;
ufoTargetY = Math.floor(Math.random() * (UFO_TARGET_Y_MAX)) * UFO_SPEED;

その他、必要な定数も定義します。

const CANNON_HEIGHT = 25;
const CANNON_Y = canvas.height - CANNON_HEIGHT - 5;
const CANNON_SPEED = 10;
const BEAM_LENGTH = 20;
const BEAM_SPEED = 10;

名前を見れば意味は分かると思いますが、CANNON_HEIGHTはキャノン砲の(画像の)高さ、CANNON_Yはキャノン砲の上端のY座標です。キャノン砲の下端がcanvasの下端から5ピクセル離れるような計算をしています。また、CANNON_SPEEDはキャノン砲が一度に動く幅、BAM_LENGTHはビームの黄色い線の長さ、BEAM_SPEEDはビームが一度に動く距離です。

アッパースネークケースを使うのはどんな時か

上で、定数はたとえばCANNON_WIDTHのように大文字をアンダースコアでつなげて表記しました。こういう記法をアッパースネークケースと言いますが、では、アッパースネークケースを使うべきなのは、どんな時でしょうか?

constで宣言した変数は値が変更できないんだから、これが定数だろう、だからconstで宣言した変数は全部アッパースネークケースにすればよいんだろう、と言えれば簡単なのですが、世間一般で、定数をアッパースネークケースにしているプログラムでも、const宣言したものを何もかもアッパースネークケースにしている例は見かけません。MDNのページには「定数は大文字または小文字で宣言することができますが、すべて大文字で宣言するのが慣例です。」としれっと書いてあるところもありますが、他のところ(たとえばここ)あたりを見ると普通にキャメルケースで書いてあって、一貫していません。この入門でも、たとえばufoImageとかcanvasとかcontextといった変数をconstで宣言していますが、アッパースネークケースではなくキャメルケースにしています。

考えてみれば、constで宣言した変数でも、それが配列やオブジェクトの参照を保持しているのであれば、その指す先の配列やオブジェクトの中身は変更可能です。指す先のものの中身も書き換えない、とはっきりわかっている場合だけ、アッパースネークケースにするべきだ、というコーディングルールもあります。たとえばAirbnbコーディングスタイルガイドなどがそうです。ただ、こちらでは、(外部ファイルから使えるように)exportされた変数以外はアッパースネークケースは使うな、となっていて、かなり限定したケースでしか使わないことになっています。Googleのコーディング規約でも、アッパースネークケースを使う前に、それが「deeply immutable」(オブジェクトが参照を含む場合、その参照の先の先まですべてが変更不能であること)であることをよく検討しろ、などと書いてあります。

また、オブジェクトや配列のような参照値を保持する変数ではなく、単なる数値型であっても、ローカル変数の場合、constでも値が毎回違う、ということがあり得ます。

function hoge(a, b) {
  const piyo = a + b; // 引数により、piyoの値は毎回変わる
  // 後続の処理
}

このようなケースでアッパースネークケースを使うのは、おそらく変だとみなされると思います。

実のところJavaScriptにおいて「どのような場合にアッパースネークケースを使うのか」について、はっきりとしたコンセンサスは得られていないように私は思います。Airbinbコーディング規約にのっとれば、CANNON_WIDTHもアッパースネークケースにしてはいけないことになりますが、まあ、日本でこれに文句を言う人はそうはいないんじゃないかなあ、という程度の判断で、私はアッパースネークケースにしていますが――実際どうなんでしょうね(弱気)。

軽微な修正と、全ソース

気に入らないところで挙げたみっつめ、「スペースキーを押しっぱなしにすると、途中からビームを連射してしまう。」についても修正します。まあこれは、連射してしまってもゲームが簡単になるわけでも難しくなるわけでもないので割とどうでもいい話ですが、気になりますので。

このゲームではスペースキーを押すことでビームを発射します。キーを押したことはonKeyDownで検知しますが、ここで試したように、キーを押しっぱなしにするとonKeyDownは「タッ、タタタタタタタタタ……」というように繰り返し呼び出されます。これにより、ビームが連射されてしまうわけです。

これを避けるには、スペースキーを離したことをonKeyReleaseで検出し、ちゃんとスペースキーを離した後でなければonKeyDownを無視すればよいわけです(「タッ、タタタタタタタタタ……」の間は、onKeyDownは繰り返し呼ばれても、onKeyReleaseは呼ばれません)。そのために、フラグspaceKeyReleasedを導入しました。

// スペースキーがちゃんと離されたかをチェックするフラグ
let spaceKeyReleased = true;

そして、スペースキーが離されたときに、このフラグをtrueにします。

function keyUp(e) {
  if (e.code === "ArrowLeft") {
    leftKeyPressed = false;
  } else if (e.code === "ArrowRight") {
    rightKeyPressed = false;
  } else if (e.code === "Space") {
    spaceKeyReleased = true;
  }
}

その上で、このフラグが立っているときだけ(スペースキーがちゃんと離されたときだけ)ビームの発射処理を行うようにすれば、押しっぱなしの間はビームは連射されません(一度発射したらフラグはfalseにします)。

function keyDown(e) {
  if (e.code === "ArrowLeft") {
    leftKeyPressed = true;
  } else if (e.code === "ArrowRight") {
    rightKeyPressed = true;
  } else if (e.code === "Space" && beams.length < 3 && spaceKeyReleased) {
    // ビームの発射処理をここに書く
    spaceKeyReleased = false;
  }
}

以上の修正を行ったUFOゲームの最終版のプログラムが以下です。

リスト9: lesson07_ufo2.html
  1: <!DOCTYPE html>
  2: <html lang="ja">
  3: <head>
  4: <meta charset="UTF-8">
  5: <title>UFOゲーム</title>
  6: </head>
  7: <body>
  8: <canvas id="canvas" width="800px" height="600px" style="background-color:black;"></canvas>
  9: <script>
 10: "use strict";
 11: 
 12: // UFOの画像読み込み
 13: const ufoImage = new Image();
 14: ufoImage.src = "./ufo.png";
 15: // UFOの画像読み込み(爆発2パターン)
 16: const ufoExplosionImage1 = new Image();
 17: ufoExplosionImage1.src = "./ufo_explosion1.png";
 18: const ufoExplosionImage2 = new Image();
 19: ufoExplosionImage2.src = "./ufo_explosion2.png";
 20: 
 21: // キャノン砲の画像読み込み
 22: const cannonImage = new Image();
 23: cannonImage.src = "./cannon.png";
 24: 
 25: const canvas = document.getElementById("canvas");
 26: const context = canvas.getContext("2d");
 27: 
 28: const UFO_WIDTH = 79;
 29: const UFO_HEIGHT = 31;
 30: const UFO_SPEED = 10;
 31: // UFOの目標点のX座標、Y座標の最大値。
 32: // UFOはUFO_SPPEDドットずつ動くので、 実際にはこれにUFO_SPEEDを掛ける。
 33: const UFO_TARGET_X_MAX = Math.floor((canvas.width - UFO_WIDTH) / UFO_SPEED) + 1;
 34: const UFO_TARGET_Y_MAX = Math.floor((canvas.height - 100) / UFO_SPEED) + 1;
 35: const CANNON_WIDTH = 50;
 36: const CANNON_HEIGHT = 25;
 37: const CANNON_Y = canvas.height - CANNON_HEIGHT - 5;
 38: const CANNON_SPEED = 10;
 39: const BEAM_LENGTH = 20;
 40: const BEAM_SPEED = 10;
 41: 
 42: // UFOの初期位置は、とりあえず左上とする
 43: let ufoX = 0;
 44: let ufoY = 0;
 45: // 「目標点」の初期位置も、左上とする
 46: let ufoTargetX = 0;
 47: let ufoTargetY = 0;
 48: 
 49: // キャノン砲の初期位置は、真ん中あたりとする
 50: let cannonX = (canvas.width - CANNON_WIDTH) / 2;
 51: 
 52: // 左右のカーソルキーが押されているかどうかを保持するフラグ
 53: let leftKeyPressed = false;
 54: let rightKeyPressed = false;
 55: // スペースキーがちゃんと離されたかをチェックするフラグ
 56: let spaceKeyReleased = true;
 57: 
 58: // ビームのX, Y座標を保持するオブジェクトの配列
 59: let beams = [];
 60: 
 61: document.onkeydown = keyDown;
 62: document.onkeyup = keyUp;
 63: 
 64: // UFOが爆発するアニメーションの繰り返し回数
 65: let ufoExplosionCounter;
 66: 
 67: mainLoop();
 68: 
 69: function mainLoop() {
 70:   // 画面をクリアする
 71:   context.clearRect(0, 0, canvas.width, canvas.height);
 72:   // UFOを描く
 73:   context.drawImage(ufoImage, ufoX, ufoY);
 74:   // キャノン砲を描く
 75:   context.drawImage(cannonImage, cannonX, CANNON_Y);
 76:   // ビームが当たったかどうかを判定する
 77:   for (let i = 0; i < beams.length; i++) {
 78:     if (beams[i].x > ufoX && beams[i].x < ufoX + UFO_WIDTH
 79:         && beams[i].y > ufoY && beams[i].y < ufoY + UFO_HEIGHT) {
 80:       ufoExplosionCounter = 0;
 81:       shootDown();
 82:       return;
 83:     }
 84:   }
 85:   // ビームを描く
 86:   context.strokeStyle = "yellow";
 87:   for (let i = 0; i < beams.length; i++) {
 88:     context.beginPath();
 89:     context.moveTo(beams[i].x, beams[i].y);
 90:     context.lineTo(beams[i].x, beams[i].y + BEAM_LENGTH);
 91:     context.stroke();
 92:   }
 93: 
 94:   // UFOを動かす
 95:   if (ufoX === ufoTargetX && ufoY === ufoTargetY) {
 96:     // UFOの位置と目標点が一致しているので、新たな目標点を設定
 97:     ufoTargetX = Math.floor(Math.random() * UFO_TARGET_X_MAX) * UFO_SPEED;
 98:     ufoTargetY = Math.floor(Math.random() * UFO_TARGET_Y_MAX) * UFO_SPEED;
 99:   }
100:   // UFOを目標点に近づける
101:   if (ufoX < ufoTargetX) {
102:     ufoX += UFO_SPEED;
103:   } else if (ufoX > ufoTargetX) {
104:     ufoX -= UFO_SPEED;
105:   }
106:   if (ufoY < ufoTargetY) {
107:     ufoY += UFO_SPEED;
108:   } else if (ufoY > ufoTargetY) {
109:     ufoY -= UFO_SPEED;
110:   }
111: 
112:   // キャノン砲を動かす
113:   if (leftKeyPressed && cannonX > 0) {
114:     cannonX -= CANNON_SPEED;
115:   } else if (rightKeyPressed && cannonX < 750) {
116:     cannonX += CANNON_SPEED;
117:   }
118:   // (発射中なら)ビームを動かす
119:   for (let i = 0; i < beams.length; ) {
120:     if (beams[i].y > 0) {
121:       beams[i].y -= BEAM_SPEED;
122:       i++;
123:     } else {
124:       beams.splice(i, 1);
125:     }
126:   }
127: 
128:   setTimeout(mainLoop, 20);
129: }
130: 
131: function keyDown(e) {
132:   if (e.code === "ArrowLeft") {
133:     leftKeyPressed = true;
134:   } else if (e.code === "ArrowRight") {
135:     rightKeyPressed = true;
136:   } else if (e.code === "Space" && beams.length < 3 && spaceKeyReleased) {
137:     const newBeam = {
138:       x: cannonX + (CANNON_WIDTH / 2),
139:       y: CANNON_Y - BEAM_LENGTH
140:     };
141:     beams.push(newBeam);
142:     spaceKeyReleased = false;
143:   }
144: }
145: 
146: function keyUp(e) {
147:   if (e.code === "ArrowLeft") {
148:     leftKeyPressed = false;
149:   } else if (e.code === "ArrowRight") {
150:     rightKeyPressed = false;
151:   } else if (e.code === "Space") {
152:     spaceKeyReleased = true;
153:   }
154: }
155: 
156: // UFOを撃墜したときに呼び出される関数
157: function shootDown() {
158:   if (ufoExplosionCounter % 2 === 0) {
159:     context.drawImage(ufoExplosionImage1, ufoX, ufoY);
160:   } else {
161:     context.drawImage(ufoExplosionImage2, ufoX, ufoY);
162:   }
163:   ufoExplosionCounter++;
164:   if (ufoExplosionCounter <= 30) {
165:     setTimeout(shootDown, 100);
166:   } else {
167:     ufoX = 0;
168:     ufoY = 0;
169:     ufoTargetX = 0;
170:     ufoTargetY = 0;
171:     beams = [];
172:     mainLoop();
173:   }
174: }
175: </script>
176: </body>
177: </html>

ここをクリックすると、このバージョンのゲームが別タブで開きます。まあ、ゲームの内容としては前回とほぼ変わりませんが。

今回は、配列とオブジェクトについて扱いました。「配列」というのは、JavaScriptに限らず、ほぼすべてのプログラミング言語が持つ機能です(JavaScriptの配列は、ちょっと奇ッ怪ではありますが)。それに対し、「オブジェクト」の方は、連想配列のような機能は今どきのプログラミング言語ならたいてい持っているものの、それにこれほど多くの機能を「寄せた」のはJavaScriptくらいなものでしょう。JavaScriptの作者ブレンダン・アイクの書いたJavaScript at Ten Yearsで、『オブジェクト指向とまではいかなくても、「オブジェクトベース」で』と書かれたのその「オブジェクトベース」の「オブジェクト」がJavaScriptにおける「オブジェクト」ということなのでしょう。

しかし、そうは言っても、現実に世の中のプログラムが欲しがったのは、「オブジェクトベース」とやらではなくて「オブジェクト指向」だったようです。

次回は(書くとすれば)「オブジェクト指向」を扱う予定です。

公開日: 2021/10/17



前のページ | 次のページ | ひとつ上のページに戻る | トップページに戻る