JavaScriptの「オブジェクト指向」はやわかり

JavaScriptには、ES6(ES2015)からクラスが導入されました。

つまりそれ以前にはクラスはなかったわけで、当時のJavaScriptプログラマは「プロトタイプベースのオブジェクト指向」の機能を使ってプログラムを書いていました。

「そんなの、今のJavaScriptプログラマが知る必要はないのでは?」と思うかもしれませんが、JavaScriptの「クラスベースの」オブジェクト指向は、元のプロトタイプベースのオブジェクト指向を元にしたものなので、やはり知らないと困ることがあります。

ひととおり、見ておくことにしましょう。

その1: JavaScriptのオブジェクトは連想配列だ

まず、JavaScriptのオブジェクトは、「文字列をキーにすることができる配列」、つまり連想配列です。こちらで説明したとおりです。ES2015からはシンボル(symbol)もキーにできるようになりましたが、そう広く使われているわけではないと思いますのでここでは無視します。

たとえば、以下のように書くと、xyというふたつのキーを持ち、それぞれに10, 20という値が格納されたオブジェクトを作れます。これは、たとえばこちらのUFOゲームにおいて、「ビーム」の座標を保持するのに使えます。

リスト1: x, yというふたつのキーを持つオブジェクトを作る
const beam = {};
beam.x = 10;
beam.y = 20;

この、キーと値の組を、プロパティ(property)と呼びます。

図にするとこうです。

図1: x, yというふたつのプロパティを持つオブジェクト

このオブジェクトに対し、beam.xまたはbeam["x"]と書けば、xというキーに紐づけられた値、つまり10が取得できます。

その2: JavaScriptの関数はオブジェクトだ

JavaScriptの関数はオブジェクトです。「その1」で書いたようにJavaScriptのオブジェクトは連想配列なので、関数にもプロパティを追加することができます(こんなことをする機会はそうそうないと思いますが)。

リスト2: 関数オブジェクトにプロパティを追加する
// 「hello」関数を作る
function hello() {
  alert("こんにちは");
}

// 関数オブジェクトhelloにプロパティxを追加する
hello.x = 10;
// 「hello.x..10」と表示される
console.log("hello.x.." + hello.x);

// 関数に対しtoString()メソッドを呼び出すと、関数のソースそのものが表示される
console.log("hello.toString().." + hello.toString());
// 「hello.toString()..function hello() {
//   alert("こんにちは");
// }」と表示される

そして、JavaScriptの関数がオブジェクトであるということは、通常のオブジェクトと同じように、その参照値を変数に代入したり、関数の引数にしたり、関数から戻り値として返したりできるということです※1

たとえば、JavaScriptでは、ポップアップでメッセージを表示するalertという関数がありますが、これを別の変数に代入すれば、その変数に関数呼び出し演算子「( )」を適用することで、同じようにメッセージ表示ができます(ここにも書きました)。

リスト3: 関数を別の変数に代入する
// 変数myAlertに、関数alertを代入する
const myAlert = alert;

// これでメッセージが表示できる
myAlert("こんにちは!");

これを図にすれば、以下のようになります。

図2: alert関数を変数に代入する

JavaScriptでは、オブジェクトは参照経由でアクセスします。関数もオブジェクトですから、参照で扱います。ここでは、alertmyAlertは、どちらも同じ関数オブジェクトを「指して」います。つまり、alertmyAlertが保持しているのは関数そのものに対する参照値です。

さて、JavaScriptでは、関数への参照値を値として扱えるので、当然、プロパティの値として、関数への参照値を保持することもできます。

リスト4は、hello()という関数を作って、それをリスト1で作ったbeamオブジェクトのsayプロパティに代入しています(say(セイ)というのは、英語で「言う」という意味です)。

リスト4: オブジェクトのプロパティに関数を代入する
// オブジェクトを作る
const beam = {};
beam.x = 10;
beam.y = 20;

// hello関数を定義する
function hello() {
  alert("こんにちは!");
}

// オブジェクトのプロパティにhello関数を代入する
beam.say = hello;

// 呼び出す
beam.say(); // 「こんにちは!」と表示される

最後の「beam.say();」で、hello()関数が呼び出されて、「こんにちは!」と表示されます。

この「beam.say();」は、単にオブジェクトのプロパティに代入された関数を呼び出しているだけなのですが、オブジェクトのメソッド(method)を呼び出しているように見えるでしょう。

――JavaScriptにおける「メソッド」は、実のところこういうものなのであって、単にオブジェクトのプロパティに代入された関数にすぎません

その3: thisは、メソッド呼び出し時の、ピリオドの左側

そうは言ってもJavaScriptのオブジェクトのメソッドは、「そのオブジェクト」に対して何らかの処理を行うものだろう、と思うでしょう。メソッドの中で、「そのオブジェクト」を意味するのがthis(ディス)です。thisは英語で「これ」という意味です※2

そして、JavaScriptにおけるthisは、メソッドを呼び出したときに、ピリオドの左側にあったオブジェクトを意味します

これは、たとえば以下のようなプログラムで実験できます。

リスト5: ひとつの関数を複数のオブジェクトのプロパティに代入する
// 「this.x」を表示する関数
function showXFunction() {
  alert("x.." + this.x);
}

// 「ビーム」のオブジェクトを作る
const beam = {};
beam.x = 10; // ビームのX座標は10
beam.y = 20;
beam.showX = showXFunction;

// 「敵(enemy)」のオブジェクトを作る
const enemy = {};
enemy.x = 100; // 敵のX座標は100
enemy.y = 200;
enemy.showX = showXFunction;

beam.showX();  // 「x..10」と表示される
enemy.showX(); // 「x..100」と表示される

この例では、「ビーム」と「敵」のふたつのオブジェクトを作り、その両方のshowXプロパティに、「this.x」を表示する関数showXFunctionを代入しています。

そしてこれを実行すると、「beam.showX();」では、beam.xである10が、「enemy.showX();」ではenemy.xである100が表示されます。呼び出しの際のピリオドの左側にあったオブジェクトのxが表示されていることがわかります。

図3: thisはピリオドの左側のオブジェクト

このように、JavaScriptにおけるthisは、その関数がメソッドとして呼び出されて初めて決まります。実際、上の例では、showXFunctionという関数は、beamenemyの両方のオブジェクトのプロパティに保持されていて、thisがどちらを指すのかは、呼び出しが行われて初めて決まっています。

他の意味のthis

上では、thisについて、「メソッド呼び出し時のピリオドの左側」と書いてしまっていますが、実のところthisが意味するのはこれだけではありません。他のケースのthisについて、以下に挙げてみます。

  1. 関数の外側では、thisグローバルオブジェクト(global object)を指す※3
  2. メソッドとしてではなく、普通に呼ばれた関数の中では、strictモードかどうかで挙動が異なる。strictモードでない場合はグローバルオブジェクトを指すが、strictモードではundefined
  3. コンストラクタ関数の中では、新たに生成したオブジェクトを指す。これについては後述します。
  4. DOMのイベントハンドラとして設定された関数の中では、設定先のDOM要素を指す。イベントハンドラでDOM要素の見た目等を変えたいときに使う。
  5. アロー関数(後述)では、その外側の構文のthisが使用される。アロー関数はforEach()とかmap()とかで気楽に使うものなので、外側のthisが見える方が都合がよいため。

詳細は、MDNのページあたりを参照してください。

関数式

リスト4やリスト5では、ここで説明した方法で関数定義を行うことで、hello()とかshowXFunction()とかの関数を作りました。ただし、プロパティに関数を設定するなら、このような方法で関数定義を行うよりも、関数式(関数定義式/function defining expression)を使う方が便利です。

関数式は、以下のように使います。

リスト6: 関数式
// beamオブジェクトを作るところは省略

// 関数式を使ってその場で関数を定義する
beam.say = function() {
  alert("こんにちは!");
};

// 呼び出す
beam.say(); // 「こんにちは!」と表示される

この例では、「function() {alert("こんにちは!")};」の部分が関数式です。この式は、関数を定義するとともに、式そのものの値としてその関数を返します。そして、上の例なら、その関数がbeam.sayに代入されているわけです。

こうして作られた関数は、名前を持ちません(無名関数と呼びます)。名前を付けたい場合は普通の関数定義と同じようにfunctionの後ろに名前を書くこともできますが、その名前は、その関数の中でしか使えません。これは再帰呼び出しをするための機能です。

また、通常の関数定義では、関数を定義する前に(ソースファイル中で、関数定義より上で)その関数を呼び出すことができますが(これを関数の「巻き上げ(hoisting)」と言います)、関数式の場合、巻き上げは発生しません。

ES6から、アロー関数(arrow function)というものが使えるようになりました。これを使うと、上のfunctionによる関数式の部分を、以下のように書き換えられます。

// アロー関数を使ってその場で関数を定義する
beam.say = () => alert("こんにちは!");

脱線:ここまでの知識で、JavaScriptで「オブジェクト指向」してみる

JavaScriptのオブジェクト指向的な機能はこれだけではないですが、ここまで出てきたその1~その3までの知識で、そこそこオブジェクト指向っぽいことはできます。やってみましょう。

リスト7: ひとまずオブジェクト指向してみる
  1: // 「敵(Enemy)」を作るコンストラクタ
  2: function newEnemy(x, y) {
  3:   const obj = {};
  4: 
  5:   obj.x = x;
  6:   obj.y = y;
  7: 
  8:   obj.draw = function() {
  9:     console.log("敵を描く");
 10:   };
 11:   obj.move = function() {
 12:     console.log("敵を動かす");
 13:   };
 14: 
 15:   return obj;
 16: }
 17: 
 18: // 「エイリアン(Alien)」を作るコンストラクタ
 19: function newAlien(x, y) {
 20:   // まずスーパークラスであるEnemyのコンストラクタでオブジェクトを作る
 21:   const obj = newEnemy(x, y);
 22: 
 23:   // 動きを変えたいメソッドだけ上書き代入して「オーバーライド」する
 24:   obj.move = function() {
 25:     this.x += 10;
 26:   };
 27: 
 28:   return obj;
 29: }
 30: 
 31: // 「エイリアン」を生成する
 32: const alien = newAlien(10, 20);
 33: 
 34: // エイリアンを描画する。これはオーバーライドしていないので、
 35: // Enemyのmove()が呼び出され、「敵を描く」と表示される。
 36: alien.draw();
 37: 
 38: // エイリアンを動かす。これはオーバーライドしているので
 39: // Alienのdraw()が呼び出され、X座標が10増える。
 40: alien.move();
 41: 
 42: // もともと10のX座標に10足しているので、「20」と表示される。
 43: console.log(alien.x);

リスト7は、こちらのシューティングゲームのように、敵(Enemy)クラスを継承して、エイリアン(Alien)クラスを作るということに近いことをしています。

2行目からのnewEnemy関数が、Enemyのコンストラクタ(という役目と決めた、ただの関数)です。出現地点のX座標、Y座標を引数として与えると、Enemyのオブジェクトを返します。8~13行目で、自身を描画するdraw()という関数と、移動させるmove()という関数をプロパティとして設定しています(上の補足で説明した、関数式の記法を使っています)。

19行目からは、Enemyの「サブクラス」であるAlienクラスのコンストラクタを作っています。まず最初に、スーパークラスであるEnemyのコンストラクタを呼び出してオブジェクトを作ってから(21行目)、オーバーライドしたいメソッドについてはプロパティを上書きしています。

こうしてやれば、32行目のようにnewAlien()関数を呼び出すことでエイリアンのオブジェクトが作れますし、そのメソッドを呼び出せば、オーバーライドされていなければスーパークラスのメソッドが(36行目)、オーバーライドされていればサブクラスのメソッドが(40行目)呼び出されます。Alienクラスのmove()メソッドではthisを参照していますが(25行目)、このthisは、40行目の「alien.move()」という呼び出しにおけるピリオドの左側、つまりalienであり、そのオブジェクトは、newAlien()関数を経由したnewEnemy()関数内で(3行目で)作られたものであることもわかるでしょう。たとえばnewAlien()関数を100回呼び出してエイリアンを100匹生成したら、それぞれ個別のx, yを持ちますが、適切な変数をピリオドの左に置いてmove()を呼び出せば、その変数が指しているエイリアンが移動する、ということになります。

――ここまでできるなら、もうこれでいいじゃん、と正直私には思えますが、JavaScriptにはもうちょっとオブジェクト指向向けの機能があります。以下で説明します。

その4: JavaScriptのオブジェクトは、「プロトタイプ」も検索する

上のリスト7では、エイリアンのオブジェクトそれぞれに、drawmoveというプロパティを持たせています。

まあこれで動きますし、問題ないといえばないのですが、エイリアンを100匹作ったとき、それぞれがdrawmoveというプロパティを持っているのは無駄ではあります。まあ、各プロパティで持っているのは関数への参照値だけなので、たいしたことはないでしょうが、メソッドがもっとたくさんあったら気になることでしょう。ゲームの画面にエイリアンが100匹登場したとき、X座標やY座標はそれぞれのエイリアンごとに違う値である必要がありますが、drawmoveはどうせ同じ関数しか指さないからです。

図4: エイリアンがたくさんいても、指すメソッドは同じもの

JavaScriptには、『オブジェクトのプロパティを探すとき、もしそのオブジェクトに指定された名前のプロパティがなかったら、「__proto__」という名前のプロパティが指す先のオブジェクトに探しに行く』という機能があります。そこにもなければ、さらにそのオブジェクトの__proto__の先を探しに行きます。あるオブジェクトの__proto__が指す先のオブジェクトのことを、そのオブジェクトのプロトタイプオブジェクト(prototype object)と呼び、プロトタイプオブジェクトを順に辿ってプロパティを探す仕組みのことをプロトタイプチェーン(prototype chain)と呼びます。

プロトタイプチェーンがあれば、エイリアンがたくさんいても、それらがすべて共通のプロトタイプオブジェクトを持っていれば、メソッドを参照するプロパティは共通にできます。

図5: プロトタイプチェーンで共通のプロパティを共有する

リスト8のようなプログラムで、「__proto__」の動きを直接確かめることができます。

リスト8: __proto__の動き
const obj1 = {
  a : 10
};

const obj2 = {
  b : 20
};

obj1.__proto__ = obj2;

// obj1にaはあるので、obj1のaの値が表示される
console.log("obj1.a.." + obj1.a);
// obj1にbはないので、__proto__を辿ってobj2のbの値が表示される
console.log("obj1.b.." + obj1.b);

この例では、obj1に存在しないbというプロパティを、「obj1.b」として参照できています。プロトタイプチェーンを辿ってobj2に探しに行っているためです。

図6: プロトタイプチェーンを辿ってプロパティを探しに行く

__proto__」って変な名前だよね

__proto__」は、アンダースコア(_)ふたつに続けてproto、さらにその後ろにアンダースコアふたつが付きます。まあ、普通に考えて、へんてこりんな名前です。なぜこんな変な名前になっているのかといえば、これはふつうのプログラマが直接触るためのプロパティではないためです。

たとえば以下のようなページを見ると、「__proto__」について、「この機能は非推奨になりました」とでかでかと書いてあります。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Object/proto

では__proto__はいつ誰が設定するのか。それについてはこの続きで。

その5: 関数にnew演算子を適用すると、「コンストラクタ関数」になる

newといえば、たとえばAlienというクラスがあったとしてnew Alien()と書けば新しいAlienのオブジェクト(インスタンス)が作られる、ということをこちらで説明しました。しかしこれは2015年のES6から導入された比較的新しい機能であり、それより前は、JavaScriptのnew演算子といえば、関数に対して適用するものでした。

たとえば以下のような関数Alien()を作ったとします。

リスト9: Alien()関数
function Alien(x, y) {
  this.x = x;
  this.y = y;
}

この関数に対して、「new Alien(10, 20)」のようにnew演算子を適用します。

関数へのnewの適用は、以下のように動作します。

リスト9のAlien関数の場合なら、①で空のオブジェクト({})が作られ、③でthisにそれが設定された後で、関数の中身の「this.x = x;」と「this.y = y;」が実行されて、④により、そのthisが返却されます。これにより、引数で渡されたxyをそれぞれx, yプロパティに設定したAlienのオブジェクトが生成されるわけです。

Alienのオブジェクトならメソッドもいるだろう、メソッドはどうするんだ、という声が聞こえてきそうですが、そこで生きてくるのが②のルールです。メソッドはあらかじめ関数のprototypeプロパティに設定しておけばよいのです。たとえばAliendraw()move()というメソッドを付けたければ、以下のように書けます。

リスト10: Alienクラスにメソッドを付ける
// Alien関数はリスト9と同じ
function Alien(x, y) {
  this.x = x;
  this.y = y;
}

Alien.prototype.move = function() {
  // moveメソッドの中身
};

Alien.prototype.draw = function() {
  // drawメソッドの中身
};

上のルールの②に「上記①で作ったオブジェクトの__proto__に、new演算子の適用対象の関数の「prototype」というプロパティに設定されているオブジェクトをセットします。」あります。このルールにより、新たに作られたオブジェクトの__proto__moveとかdrawとかを持っているオブジェクトを指すようになりますから、プロトタイプチェーン経由で普通にdraw()とかmove()が呼べるようになります。もちろん、エイリアンのインスタンスをたくさん作っても、prototypeの指す先のオブジェクトは共有されます(図8)。

図8: Alien関数のprototype__proto__に設定される

ここまでやったら継承が欲しい、と思うかもしれません。たとえばAlienクラスをEnemyクラスのサブクラスにしたいとして、これを雑に実現するならAlienprototypeEnemyのインスタンスを設定すればできます。ここでは、例として、Enemymove()メソッドだけオーバーライドし、draw()メソッドはEnemyのものをそのまま使うことにしましょう。

リスト11: 継承の(雑な)実現
// Enemyのコンストラクタ関数(空でよい)
function Enemy() {
}

Enemy.prototype.draw = function() {
  // 敵を描画する関数(オーバーライドしなければこれが使われる)
};

Enemy.prototype.move = function() {
  // 敵を動かす関数(オーバーライドしなければこれが使われる)
};

// Alien関数はリスト9, 10と同じ
function Alien(x, y) {
  this.x = x;
  this.y = y;
};

// Alienのprototypeの先にEnemyのオブジェクトをくっつける
Alien.prototype = new Enemy();

// moveメソッドについてだけオーバーライドする(drawメソッドはEnemyのものを使う)
Alien.prototype.move = function() {
  // moveメソッドの中身
};

こうしておけば、「new Alien(x, y)」としてエイリアンを作ると、図9のような構造になります。

図9: Enemyを継承してAlienを作る

Alien関数のprototypeEnemyのオブジェクトを紐づけていますから、ここでエイリアンをnewすれば、生成されたエイリアンの__proto__Enemyオブジェクトを指します。このEnemymoveは書き換えられているのでエイリアンを動かす関数を指しています。drawは書き換えられていないので、Enemyオブジェクトの__proto__を辿って、Enemyprototypedrawが指す関数、つまりオーバーライド前のEnemy共通の関数が呼び出されます。

この方法では、Alienがたくさん作られても、それに紐づけられたEnemyオブジェクトはひとつしかありません。よって、スーパークラスであるEnemyに、それぞれの敵個別で保持したいデータのプロパティを保持することはできません。この方法が雑な実現だと書いたのはそのためです。

雑でない、(ある程度)まともな方法は結構面倒ですし、クラスが使える今そんな方法を覚える価値もないと思いますが、以下の記事が参考になるかと思います(こんなことをいまどきやろうとする人はいないので、どちらもかなり古い記事です)。

まとめとして、クラスを使わずに作ったシューティングゲームのリストを以下に載せます。

リスト12: クラスを使わずに作ったシューティングゲーム
  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: function Beam(x, y) {
 87:   this.x = x;
 88:   this.y = y;
 89: }
 90: 
 91: Beam.prototype.draw = function(context) {
 92:   context.strokeStyle = "yellow";
 93:   context.beginPath();
 94:   context.moveTo(this.x, this.y);
 95:   context.lineTo(this.x, this.y + 20);
 96:   context.stroke();
 97: };
 98: 
 99: Beam.prototype.move = function() {
100:     this.y -= 10;
101: };
102: 
103: // エイリアンの状態を示す列挙型
104: const AlienMode = {
105:   TO_RIGHT : 0, // 右に移動中
106:   TO_LEFT  : 1, // 左に移動中
107:   TURNING  : 2, // 旋回中
108:   FALLING  : 3  // 落下中
109: };
110: Object.freeze(AlienMode);
111: 
112: function Enemy() {
113:   this.hitCounter = 0;
114:   this.hitLimit = 1;
115: }
116: 
117: Enemy.prototype.draw = function(context) {
118:   context.drawImage(this.currentImage,
119:                     this.x - (this.currentImage.width / 2),
120:                     this.y - (this.currentImage.height / 2));
121: };
122: 
123: Enemy.prototype.hitTest = function(x, y) {
124:   const hitBox = this.getHitBox();
125:   if (x > hitBox.xMin && x < hitBox.xMax
126:       && y > hitBox.yMin && y < hitBox.yMax) {
127:     this.hitCounter++;
128:     if (this.hitCounter === this.hitLimit) {
129:       return HitTestResult.HIT_AND_DESTROY;
130:     } else {
131:       return HitTestResult.HIT;
132:     }
133:   }
134:   return HitTestResult.NOT_HIT;
135: };
136: 
137: Enemy.prototype.getHitBox = function() {
138:   return new HitBox(this.x - (this.currentImage.width / 2),
139:                     this.y - (this.currentImage.height / 2),
140:                     this.x + (this.currentImage.width / 2),
141:                     this.y + (this.currentImage.height / 2));
142: };
143: 
144: function Alien() {
145:   this.currentImage = alienImageNormal;
146:   this.x = 0;
147:   this.y = Math.random() * 300 + (this.currentImage.height / 2);
148:   this.mode = AlienMode.TO_RIGHT;
149: };
150: 
151: Alien.prototype = new Enemy();
152: 
153: Alien.prototype.move = function() {
154:   if (this.mode === AlienMode.TO_RIGHT || this.mode === AlienMode.TO_LEFT) {
155:     if (Math.random() < 0.005) {
156:       this.mode = AlienMode.TURNING;
157:       this.counter = 1;
158:     } else {
159:       if (this.mode === AlienMode.TO_RIGHT) {
160:         if (this.x < canvas.width - this.currentImage.width / 2) {
161:           this.x += 5;
162:         } else {
163:           this.x -= 5;
164:           this.mode = AlienMode.TO_LEFT;
165:         }
166:       } else {
167:         if (this.x > this.currentImage.width / 2) {
168:           this.x -= 5;
169:         } else {
170:           this.x += 5;
171:           this.mode = AlienMode.TO_RIGHT;
172:         }
173:       }
174:     }
175:   } else if (this.mode === AlienMode.TURNING) {
176:     if (this.counter === 1) {
177:       this.currentImage = alienImageR1;
178:       this.x += 5;
179:     } else if (this.counter === 2) {
180:       this.currentImage = alienImageR2;
181:       this.x += 5;
182:       this.y += 5;
183:     } else if (this.counter === 3) {
184:       this.currentImage = alienImageR3;
185:       this.x += 5;
186:       this.y += 8;
187:     } else if (this.counter === 4) {
188:       this.currentImage = alienImageR4;
189:       this.x += 5;
190:       this.y += 10;
191:     } else if (this.counter === 5) {
192:       this.mode = AlienMode.FALLING;
193:       const hypotenuse = Math.sqrt((cannonX - this.x) * (cannonX - this.x)
194:                                    + (canvas.height - this.y) * (canvas.height - this.y));
195:       this.xSpeed = (cannonX - this.x) / hypotenuse * 10;
196:       this.ySpeed = (canvas.height - this.y) / hypotenuse * 10;
197:       if (this.xSpeed < 0) {
198:         this.currentImage = alienImageL4;
199:       }
200:     }
201:     this.counter++;
202:   } else if (this.mode === AlienMode.FALLING) {
203:     this.x += this.xSpeed;
204:     this.y += this.ySpeed;
205:   }
206: 
207:   if (this.mode != AlienMode.TURNING) {
208:     if (Math.random() < 0.01) {
209:       enemies.push(new EnemyBomb(this.x, this.y));
210:     }
211:   }
212:   return AfterMove.NORMAL;
213: };
214: 
215: function FlyingBoard() {
216:   this.hitLimit = 5;
217:   this.x = Math.random() * canvas.width;
218:   this.y = 0;
219:   this.counter = 0;
220:   this.currentImage = flyingBoardImage[0];
221: }
222: 
223: FlyingBoard.prototype = new Enemy();
224: 
225: FlyingBoard.prototype.move = function() {
226:   this.y += 5;
227:   this.counter++;
228:   this.currentImage = flyingBoardImage[Math.floor(this.counter / 3) % 4];
229:   return AfterMove.NORMAL;
230: };
231: 
232: function Ufo() {
233:   this.x = 0;
234:   this.y = 0;
235:   this.targetX = 0;
236:   this.targetY = 0;
237:   this.counter = 0;
238:   this.currentImage = ufoImage;
239:   ufoFlag = true;
240: }
241: 
242: Ufo.prototype = new Enemy();
243: 
244: Ufo.prototype.move = function() {
245:   if (this.counter < 200) {
246:     if (this.x === this.targetX && this.y === this.targetY) {
247:       // UFOの目標点のX座標、Y座標の最大値。
248:       // UFOはUFO_SPPEDドットずつ動くので、 実際にはこれにUFO_SPEEDを掛ける。
249:       const ufoTargetXMax = Math.floor(canvas.width  / UFO_SPEED) + 1;
250:       const ufoTargetYMax = Math.floor((canvas.height - 100) / UFO_SPEED) + 1;
251:       this.targetX = (Math.floor(Math.random() * ufoTargetXMax) * UFO_SPEED);
252:       this.targetY = (Math.floor(Math.random() * ufoTargetYMax) * UFO_SPEED);
253:     }
254:     if (this.x < this.targetX) {
255:       this.x += UFO_SPEED;
256:     } else if (this.x > this.targetX) {
257:       this.x -= UFO_SPEED;
258:     }
259:     if (this.y < this.targetY) {
260:       this.y += UFO_SPEED;
261:     } else if (this.y > this.targetY) {
262:       this.y -= UFO_SPEED;
263:     }
264:     this.counter++;
265:   } else {
266:     this.y -= UFO_EXIT_SPEED;
267:     if (this.y < 0) {
268:       ufoFlag = false;
269:       return AfterMove.DELETE;
270:     }
271:   }
272:   return AfterMove.NORMAL;
273: };
274: 
275: Ufo.prototype.hitTest = function(x, y) {
276:   const result = Enemy.prototype.hitTest.call(this, x, y);
277:   if (result === HitTestResult.HIT_AND_DESTROY) {
278:     ufoFlag = false;
279:   }
280:   return result;
281: };
282: 
283: function EnemyBomb(x, y) {
284:   this.x = x;
285:   this.y = y;
286: }
287: 
288: EnemyBomb.prototype = new Enemy();
289: 
290: EnemyBomb.prototype.move = function() {
291:   this.y += 8;
292: };
293: 
294: EnemyBomb.prototype.draw = function(context) {
295:   context.fillStyle = "pink";
296:   context.beginPath();
297:   context.arc(this.x, this.y, 3, 0, Math.PI * 2);
298:   context.fill();
299: };
300: 
301: EnemyBomb.prototype.hitTest = function(x, y) {
302:   return HitTestResult.NOT_HIT;
303: };
304: 
305: EnemyBomb.prototype.getHitBox = function() {
306:   return new HitBox(this.x - 1, this.y - 1, this.x + 1, this.y + 1);
307: };
308: 
309: function Explosion(x, y) {
310:   this.x = x;
311:   this.y = y;
312:   this.counter = -1;
313: };
314:   
315: Explosion.prototype.draw = function(context) {
316:   const currentImage = explosionImage[this.counter];
317:   context.drawImage(currentImage, 
318:                     this.x - (currentImage.width / 2),
319:                     this.y - (currentImage.height / 2));
320: };
321: 
322: function HitBox(xMin, yMin, xMax, yMax) {
323:   this.xMin = xMin;
324:   this.yMin = yMin;
325:   this.xMax = xMax;
326:   this.yMax = yMax;
327: }
328: 
329: function initialize() {
330:   ufoFlag = false;
331:   cannonX = (canvas.width - cannonImage.width) / 2;
332:   leftKeyPressed = false;
333:   rightKeyPressed = false;
334:   gameOverFlag = false;
335: 
336:   beams = [];
337:   enemies = [];
338:   explosions = [];
339: 
340:   mainLoop();
341: }
342: 
343: function mainLoop() {
344:   // 新しい敵の出現処理
345:   if (Math.random() < 0.01) {
346:     enemies.push(new Alien());
347:   } else if (Math.random() < 0.02) {
348:     enemies.push(new FlyingBoard());
349:   } else if (Math.random() < 0.01 && !ufoFlag) {
350:     enemies.push(new Ufo());
351:   }
352: 
353:   // 画面をクリアする
354:   context.clearRect(0, 0, canvas.width, canvas.height);
355: 
356:   // 敵を描く
357:   for (let i = 0; i < enemies.length; i++) {
358:     enemies[i].draw(context);
359:   }
360:   // キャノン砲を描く
361:   if (!gameOverFlag) {
362:     // 通常状態
363:     context.drawImage(cannonImage,
364:                       cannonX - (cannonImage.width / 2),
365:                       canvas.height - cannonImage.height - BOTTOM_MARGIN);
366:   } else {
367:     // ゲームオーバー表示時は爆発パターンを描く
368:     const expCounter = Math.floor(cannonExplosionCounter / 3);
369:     if (expCounter < cannonExplosionImage.length) {
370:       const explosionImage = cannonExplosionImage[expCounter];
371:       context.drawImage(explosionImage,
372:                         cannonX - (explosionImage.width / 2),
373:                         canvas.height - BOTTOM_MARGIN - explosionImage.height);
374:       cannonExplosionCounter++;
375:     }
376:   }
377:   for (let i = 0; i < beams.length; i++) {
378:     beams[i].draw(context);
379:   }
380:   for (let i = 0; i < explosions.length; i++) {
381:     explosions[i].draw(context);
382:   }
383: 
384:   // ビームと敵の衝突判定
385:   for (let beamIdx = 0; beamIdx < beams.length; ) {
386:     let beamHitFlag = false;
387: 
388:     for (let enemyIdx = 0; enemyIdx < enemies.length; ) {
389:       const hit = enemies[enemyIdx].hitTest(beams[beamIdx].x, beams[beamIdx].y);
390:       if (hit === HitTestResult.HIT || hit === HitTestResult.HIT_AND_DESTROY) {
391:         beamHitFlag = true;
392:         if (hit === HitTestResult.HIT_AND_DESTROY) {
393:           explosions.push(new Explosion(enemies[enemyIdx].x, enemies[enemyIdx].y));
394:           enemies.splice(enemyIdx, 1);
395:         } else {
396:           enemyIdx++;
397:         }
398:         break;
399:       } else {
400:         enemyIdx++;
401:       }
402:     }
403:     if (beamHitFlag) {
404:       beams.splice(beamIdx, 1)
405:     } else {
406:       beamIdx++;
407:     }
408:   }
409: 
410:   if (!gameOverFlag) {
411:     // 敵とキャノン砲の衝突判定
412:     for (let enemyIdx = 0; enemyIdx < enemies.length; enemyIdx++) {
413:       const hitBox = enemies[enemyIdx].getHitBox();
414:       if (!(hitBox.xMin > (cannonX + cannonImage.width / 2)
415:             || hitBox.yMin > canvas.height - BOTTOM_MARGIN
416:             || hitBox.xMax < (cannonX - cannonImage.width / 2)
417:             || hitBox.yMax < canvas.height - cannonImage.height - BOTTOM_MARGIN)) {
418:         gameOverFlag = true;
419:         leftKeyPressed = false;
420:         rightKeyPressed = false;
421:         gameOverCounter = 0;
422:         cannonExplosionCounter = 0;
423:       }
424:     }
425:   }
426:   for (let i = 0; i < enemies.length; ) {
427:     const moveStatus = enemies[i].move();
428:     if (enemies[i].y > canvas.height + 50 || moveStatus === AfterMove.DELETE) {
429:       enemies.splice(i, 1);
430:     } else {
431:       i++;
432:     }
433:   }
434:   if (leftKeyPressed && cannonX > 0) {
435:     cannonX -= CANNON_SPEED;
436:   }
437:   if (rightKeyPressed && cannonX < canvas.width) {
438:     cannonX += CANNON_SPEED;
439:   }
440:   for (let i = 0; i < beams.length; ) {
441:     beams[i].move();
442:     if (beams[i].y < 0) {
443:       beams.splice(i, 1);
444:     } else {
445:       i++;
446:     }
447:   }
448:   for (let i = 0; i < explosions.length; ) {
449:     explosions[i].counter++;
450:     if (explosions[i].counter === explosionImage.length) {
451:       explosions.splice(i, 1);
452:     } else {
453:       i++;
454:     }
455:   }
456: 
457:   if (gameOverFlag) {
458:     gameOverCounter++;
459:     if (gameOverCounter > 200) {
460:       initialize();
461:       return;
462:     }
463:   }
464:   setTimeout(mainLoop, 20);
465: }
466: 
467: function keyDown(e) {
468:   if (gameOverFlag) {
469:     return;
470:   }
471:   if (e.code === "ArrowLeft") {
472:     leftKeyPressed = true;
473:   } else if (e.code === "ArrowRight") {
474:     rightKeyPressed = true;
475:   } else if (e.code === "Space" && beams.length < 10 && spaceKeyReleased) {
476:     beams.push(new Beam(cannonX,
477:                         canvas.height - BOTTOM_MARGIN - cannonImage.height  - BEAM_LENGTH));
478:     spaceKeyReleased = false;
479:   }
480: }
481: 
482: function keyUp(e) {
483:   if (e.code === "ArrowLeft") {
484:     leftKeyPressed = false;
485:   } else if (e.code === "ArrowRight") {
486:     rightKeyPressed = false;
487:   } else if (e.code === "Space") {
488:     spaceKeyReleased = true;
489:   }
490: }
491: 
492: function loadImage(fileName) {
493:   const image = new Image();
494:   loadingStatus[fileName] = false;
495:   image.src = fileName;
496:   let loadedAll = true;
497:   image.onload = function() {
498:     const keys = Object.keys(loadingStatus);
499:     loadingStatus[fileName] = true;
500:     for (let i = 0; i < keys.length; i++) {
501:       if (!loadingStatus[keys[i]]) {
502:         loadedAll = false;
503:       }
504:     }
505:     if (loadedAll) {
506:       initialize();
507:     }
508:   }
509:   return image;
510: }
511: </script>
512: </body>
513: </html>

このリストをちゃんと読んだ人は、以下の部分を見て、

112: function Enemy() {
113:   this.hitCounter = 0;
114:   this.hitLimit = 1;
115: }

「あれ? この雑な継承では、それぞれの敵個別で保持したいデータをスーパークラスに持たせてはいけなかったのでは? hitLimitは敵の種類ごとに固定だからいいけど、hitCounterをここで持ったらまずいだろう」と思ったかもしれません。

JavaScriptでは、指定したプロパティがそのオブジェクトになかったら、__proto__を辿って探しに行きますが、そのオブジェクトにあれば、そのオブジェクトのプロパティを参照します。そして、プロパティは、代入によって作り出されます。よって、スーパークラスのプロパティに新しい値を代入しようとしても、それはサブクラス側へのプロパティの追加を意味します。この例でいえば、127行目の「this.hitCounter++;」により、Alienとかのサブクラス側にhitCounterが新設されるので、インスタンスごとに個別のhitCounterを持つことになります。

127:     this.hitCounter++;

インスタンスベース? オブジェクトベース?

「クラスベース」のオブジェクト指向言語では、クラスというひな型をもとにインスタンスを作ります。たとえばJavaなら、同じクラスから作られたインスタンスは、すべて同じメソッドと同じデータフィールドを持ちます。RubyやPythonだとデータフィールド(インスタンス変数)は代入により実行時に作られるのでインスタンスごとに異なる可能性がありますが、メソッドについては、同じクラスであれば、基本的に共通です(基本的に、と書いているのは、Rubyは特異メソッドという機能でインスタンス固有のメソッドを作れるからです)。

それに対し、JavaScriptでは、オブジェクトは単なる連想配列です。class構文やコンストラクタ関数を使って「特定のメソッド群を持つオブジェクト」をいくつも生成することはできますが、そんなものを使わなくてもオブジェクトは作れますし、メソッドを付けることもできます。よって、インスタンスに対するメソッドは、すべてのインスタンスごとに個別に変更できます。

これがJavaScriptのような、クラスベースではないオブジェクト指向言語の特徴であると私は思います。

ここまで、「プロトタイプ」という言葉は出てきていません。このページで言えば、「プロトタイプ」という言葉が出てきたのは『その4: JavaScriptのオブジェクトは、「プロトタイプ」も検索する』からで、その前の『脱線:ここまでの知識で、JavaScriptで「オブジェクト指向」してみる』において普通にオブジェクト指向っぽいことは(継承含めて)実現できています。プロトタイプは、たくさんのオブジェクトが同じメソッドを持つときにちょっとメモリを節約できるとか、プロトタイプをいじることでたくさんのオブジェクトにまとめてメソッドを追加したり動きを変えたりできるとか※4というメリットはありますが、正直、これが本質だとは私には思えません。

なので、このような言語のことを「プロトタイプベース」と呼ぶのはやめて、「インスタンスベース」とか「オブジェクトベース」と呼ぶようにしよう、という意見もあります。実際、JavaScriptの作者であるブレンダン・アイクも、「"Object-based"」という言葉を使っていたわけですし。

そういえば、前述の通り、Rubyには特異メソッドという、インスタンスごとのメソッドを定義する機能がありますが、そのユーザーズガイドを見ると、以下のように書いてあります。

特異メソッドは別にrubyだけのオリジナルじゃない.珍しいのは確かだけど.rubyの他に特異メソッドを持っているのはCLOSやDylan などがある.中にはSelfやNewtonScriptのように特異メソッドしか持たないものもある.そういう言語をプロトタイプベースという.

JavaScriptも、メソッドはインスタンスごとなので、ここで言う「特異メソッドしか持たない」言語に相当します(ここで例に挙がっていないのは、このユーザーズガイドがかなり古く、まだJavaScriptの本格的な普及前だったからでしょう)。「特異メソッドしか持たない」言語をプロトタイプベースという、と言われても、「特異メソッドしか持たない」言語にプロトタイプは必須ではないので、これはやっぱり用語が混乱しているのだと思います。

公開日: 2022/10/10



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