STGつくろー!
その4 自機から弾を飛ばす
@ ToDoリストを考える
STGは弾を打てなければいけないですね。前回自機の移動までは何となく実装したので、今回は「打て」と命令されたら弾を飛ばすという機構をYAGNIの原則で考えて見ます。
自機は弾を持っていて、「打て」と命令されたらそれを飛ばす。飛ばすというのは、弾が勝手に飛んでいくと考えてよいでしょう。ということは、
・ 弾自体も「飛べ」と命令されなければ飛ばない
・ 弾は自分の飛ぶ方向や1単位で動く距離などを知っている
となります。これをToDoリストとして定義しましょう。
ToDoリスト ・ Player1は打てと命令されると弾を作る ・ 打たれた弾の初期位置はPlayer1の位置と同じ ・ 弾は自分の飛ぶ方向や1単位で動く距離を知っている ・ 打たれた弾は命令されると飛んで位置を変える
A Player1は打てと命令されると弾を作る
自機は打てという命令をどこからか受けるはずです。命令された時に弾を作成し外部に渡します。ここまでが1段目のToDoリストの内容です。まずは、このテストコードを書きましょう。
引き続きCPlayerTestクラスにtestGetBullet関数を設けます。関数内は次のようにしました。
PlayerTest.h void testGetBullet(){
CPlayer Player1;
CBullet *pBullet;
assertTypeEquals(pBullet, Player1.Shoot());
}
コンパイルすると当然エラーとなりますので、エラーを解消して行きます。
assertTypeEquals関数という新しい関数を作ることにしました。これは、第1引数のポインタ型に第2引数のポインタ型が代入可能かを判定します。つまりアップキャストの可否を判定する関数です。この関数は「テンプレート関数」を用います。
TestCase.h template <class T1, class T2> void assertTypeEquals(T1* expect, T2* val)
{
T1 me* = val;
if(!me)
}
こうすることで、どんなクラスでも代入の可否を比較できます。テストプログラムに直接代入文のを書き込んでも良いのですが、こうした方が何をテストしているかが良く分かるので、お勧めです。
「CPlayerクラスにShoot関数はない」というエラーも出ました。よって、この関数を作ります。この関数の目的は、呼ばれたら弾(CBulletオブジェクト)を生成して、その弾へのポインタを渡します。
Player.cpp CBullet* CPlayer::Shoot()
{
// 弾を生成
return new CBullet;
}
コンパイルを回避する最小プログラムなので、今はこれで十分です。ここでコンパイルすると、エラーがどばーっと出てきます。しかし、その殆どが「CBulletクラスがない」というものなので、新しくCBulletクラスを作ります。
Bullet.h class CBullet
{
public:
CBullet();
virtual ~CBullet();
};
Bullet.hをPlayer.hにインクルードすると、エラーは一気に解消されて、ノーエラーになります!
レッドシグナルにするには、assertTypeEquals関数の第1引数にCBulletクラス以外のオブジェクトを入れます。その後、改めてCBulletオブジェクトへのポインタを入れ、グリーンシグナルになることを確認します。これで、ToDoリストの1段目は終了です!
B 打たれた弾の初期位置はPlayer1の位置と同じ
続いてToDoリストの2段目に移ります。
打たれた弾の初期位置は基本的にPlayer1の位置と同じにしたいと考えます。こういう生成と同時に決めるものは、引数付きコンストラクタに限りますね。ついでに、弾の位置を取得する関数も生成し、テストで比較できるようにしましょう。
これは純粋に弾の問題ですから、新しいテストクラスCBulletTestクラスを作ります。さらに、テスト関数として、testGetBulletPosition関数を追加します。テストコードはこんな感じでしょう。
BulletTest.h void testGetBulletPosition(){
CBullet Bullet1(50.0, 50.0、50.0);
assertEquals(100.0, Bullet1.GetPosition().x);
assertEquals(100.0, Bullet1.GetPosition().y);
assertEquals(100.0, Bullet1.GetPosition().z);
}
コンパイルエラーは大きく2つ。「コンストラクタがない」「GetPosition関数は知らない」です。CBulletクラスのコンストラクタに引数をつける改良を施します。
BulletTest.cpp CBullet::CBullet(double x, double y, double z)
{
m_Position.x = x;
m_Position.y = y;
m_Position.z = z;
}
m_Positionは前に作ったPOSITION型を流用させてもらいましょう。メンバ変数もしっかり設定します。次に、GetPosition関数を作ります。この戻り値もPOSITION型です。
この段階でコンパイルし直すと、1つだけエラーが出ます。それは、CPlayerクラスのShoot関数内でデフォルトコンストラクタが無いというものです。先ほど、この関数は、
Player.cpp CBullet* CPlayer::Shoot()
{
// 弾を生成
return new CBullet;
}
と定義しました。なるほど、new演算子の後ろのCBulletがデフォルトコンストラクタになってます。これを、引数付きコンストラクタに変更します。引数には、CPlayerクラスの現在位置を与えます。
Player.cpp CBullet* CPlayer::Shoot()
{
// 弾を生成
return new CBullet(m_Positon.x, m_Position.y, m_Position.z);
}
これでコンパイルするとノーエラーとなります。
さて、次にテストを成功させましょう。この段階で実行すると、実行時エラーが出ます。
エラー内容 ■■ 期待値は 100.000000 でしたが、引数は 50.000000 となり異なっています。
■■ 期待値は 100.000000 でしたが、引数は 50.000000 となり異なっています。
■■ 期待値は 100.000000 でしたが、引数は 50.000000 となり異なっています。
いい感じですね。期待値をすべて50.0にすれば、オールグリーンになります。ToDoリスト2段目終了。
B 弾は自分の飛ぶ方向や1単位で動く距離を知っている
ToDoリスト3段目です。生成した弾は、勝手に飛んで行くようにしたいのです。そのために、CPlayerクラスと同じように方向を決めるSetDirectionZY関数および1ステップ進むGoNext関数を考えます。まず、CBulletTest::testSetDirection関数を定義して、次のテストコードを作成します。
BulletTest.h void testSetDirection(){
CBullet Bullet1(50.0, 50.0、50.0);
Bullet1.SetDirectionZY(45, 45); // Z軸45度回転、Y軸45度回転
Bullet1.GoNext();
assertEquals(100.0, Bullet1.GetPosition().x);
assertEquals(100.0, Bullet1.GetPosition().y);
assertEquals(100.0, Bullet1.GetPosition().z);
}
これを見て、「これ前にやったなぁ」と感じることが大切。こういうところは間違いなく「リファクタリング」の対象になります。今は、テストに通ることを第1に考えますので、面倒ですがそのまんま実装します。内容はCPlayerとまったく一緒ですから、割愛します(その3をご覧下さい)。
まったく同じ実装をすれば、コンパイルは通りますね。クロスチェックもちゃんとします。実行時テストもいつものように、レッドシグナルを出してから、グリーンシグナルを点灯させます。リファクタリングできそうなところを忘れずに、ToDoリスト4段目に行きます。
C 打たれた弾は命令されると飛んで位置を変える
あ、もう終わっていますね。
これで、弾に関するTDDによるテストコードと実装は一応終了です。
D リファクタリングしよう
この章のメインは多分ここです。
BでCBulletのメンバ関数を実装する段階で、「これ、CPlayerと似てるなぁ」と感じました。こういう点はリファクタリングの要所です。
リファクタリングの1つに「抽象化」があります。同じような部分、2重に書いている部分を積極的に抽出して、1つにまとめてしまいます。今回の場合は、相当に大きな抽出が可能です。CBulletクラスとCPlayerクラスは、次の点がまったく同じです。
・ GetPosition関数
・ SetPosition関数
・ SetDirectionZY関数
・ GoNext関数
・ 各種メンバ変数
これは明らかに親クラスを作るべきであることを示しています。そこで、これらを持つ親クラスをCCharacterクラスとして新規定義しましょう。
CCharacter.h class CCharacter
{
private:
POSITION m_Position; // 位置
double m_dZAxisDirect; // Z軸回転角度
double m_dYAxisDirect; // Y軸回転角度
double m_dNext_X; // X座標差分値
double m_dNext_Y; // y座標差分値
double m_dNext_Z; // z座標差分値
public:
CCharacter();
virtual ~CCharacter();
void GoNext();
void SetDirectionZY(double z_axis_direct, double y_axis_direct);
void SetPosition(double x, double y, double z);
POSITION GetPosition();
};
さらに、CPlayerクラス及びCBulletクラスをCCharacterクラスの派生クラスに再設定し、重複する関数を取り除いて行きます。これをすると劇的です。まずCBulletクラスは引数付きコンストラクタとデストラクタ以外はすっきり無くなってしまいます。CPlayerクラスはShoot関数が追加されているだけ。あとは、全部CCharacterクラスが受け持ってくれます。
上のメンバ変数でm_dNext_X、m_dNext_Y、m_dNext_Zは分離して持つ理由はありません。これは、POSITION型であるm_NextDifPosとしてまとめてしまいます。
さらにリファクタリングします。リファクタリングの手法の1つに「一般化」があります。CCharacterを見てまず考える一般化はPLAYER_MOVEUNITというマクロ定数です。これは、1単位での移動距離を設定したものですが、これは定数ではなくて変数として一般化した方が良いでしょう。そこで、これをm_dMoveUnitという変数に変更し、マクロを消します。
さて、コンパイラが通ったら、実行してみます。そうすると、何と例外エラーが発生してしまいました。こういう場合はステップ実行をして見ます。するとassertEquals関数内で例外が発生しているようです。どうやら、文字列格納用の配列数が少なすぎるようです。すぐに変更します。リファクタリングをすることによって、こういうエラーが発生することは良くあります。それは必ずすべて解決しなければなりません。解決しない前に新しい開発をすると、TDDは崩壊します。
例外エラーを回避した後、実行時エラーが再発しました。これもリファクタリングの影響です。
エラー内容 ■■ CPlayer.h: 期待値は 1.000000 でしたが、引数は -46279815676736725000000000000000000000000000000000000000000000.000000 となり異なっています。
■■ CPlayer.h: 期待値は 1.000000 でしたが、引数は -46279815674658915000000000000000000000000000000000000000000000.000000 となり異なっています。
■■ CPlayer.h: 期待値は 1.414214 でしたが、引数は -65449542989760359000000000000000000000000000000000000000000000.000000 となり異なっています。
なんじゃこりゃ!となりますが、これもステップ実行するとどこでエラーが起きたか簡単にわかります。実は、単位移動距離として一般化したm_dMoveUnitをCCharacterクラスのコンストラクタで初期化していなかったんですね。こういうのがあっという間に分かるのが、この手法の凄いところだと思います。これも、テストコードがあるおかげです!m_dMoveUnit = 2としておけば、テストはオールグリーンで通ります。
これで、気になるリファクタリングは終了します。またきっと後でしたくなります。TDDではリファクタリングしたくなったらします。それがうまく動いているプログラムでも親クラスであったとしてもです。「え!それじゃ他のクラスへの影響が心配だ!」と考えてしまいますが、そのために徹底したテストコードがあるわけなんです。TDDでは変更は積極的、XPでいう変更する勇気が沸くんです。