<戻る

STGつくろー!
その3 自機に必要な要素は何か?


@ YAGNIとTDDでシンプルに考える

 STGの自機に必要な要素は何か?まずはこんな所から開発を始めたいと思います。STGを頭に思い浮かべると、まず自機は操作によって位置を変えて、弾を撃つ。あれ?これだけかもしれません。この章は極短で終了か!

 とりあえずYAGNIの原則とTDDで考えます。テストコードとしてCPlayerTestクラス(その2で作ったクラス)を流用します。上の必要事項からToDoリストは次のようになるでしょうか。

ToDoリスト
・ Player1の初期取得位置は(0, 0, 0)となる
・ Player1の位置変更後、変更した位置が取得できる
・ Player1は指定された方向に行けと命令されると1単位分位置を変える
・ Player1は打てと命令されると弾を打つ

まぁ、YAGNIだし・・・

 ToDoリストの上2つはすでに「その2」でテスト済みです。3段目から行きましょう。CPlayerTestクラスにtestSetDirection関数を追加します。これは、自機が方向を指定されたらその方向に移動するかをテストする関数です。

PlayerTest.h
#include "Player.h"

class CPlayerTest
{
public:
   CPlayerTest();
   virtual ~CPlayerTest();

   void testSetDirection(){
      CPlayer Payer1;
      // とりあえず適当な期待値を与えます
    Player1.SetDirection(45.0);           // 45度
     Player1.GoNext();                       // 次の位置へ進め
      assertEquals(100, Player1.GetPosition().x);
      assertEquals(100, Player1.GetPosition().y);
      assertEquals(100, Player1.GetPosition().z);
   }
};

 SetDirection関数で動く角度を指定して、GoNext関数でその角度に1単位進ませる予定です。コンパイルするとエラーは4つ。「そんな関数は知りません」というエラーです。これを元にCPlayerクラスを修正します。これは簡単で、それぞれのメンバ関数を追加するだけです。SetDirection関数の引数はdouble型にして、それを格納するm_dDirection変数をクラスに追加します。GoNext関数内は今は空です。これが最小のプログラム。以上修正し、コンパイルしてもエラーにはなりません(TDDではこれが大切)。

 さて、このテスト関数を実行します。WinMain関数に追加です。

PlayerTestProgram.cpp
#include "stdafx.h"
#include "PlayerTest.h"

int APIENTRY WinMain(HINSTANCE hInstance,
HINSTANCE hPrevInstance,
LPSTR lpCmdLine,
int nCmdShow )
{
   CPlayerTest Obj;
   Obj.testGetPlayerPosition(); // 位置取得テスト
   Obj.testSetPlayerPosition(); // 位置変更テスト
   Obj.testSetPlayerDirection(); // 方向指定移動テスト

   return 0;
}

 ここで、決して「前回のテスト部分を消去してはいけません」!これらはすべて、後の変更による影響を検査できる大切なテストプログラムです。

 実行結果、次の実行時エラーが出ました。

エラー内容
■■ 期待値は 100 でしたが、引数は 0 となり異なっています。
■■ 期待値は 100 でしたが、引数は 0 となり異なっています。
■■ 期待値は 100 でしたが、引数は 0 となり異なっています。

ToDoリストでは指定の方向に1単位移動することになっていますが、GoNext関数が空なので、初期位置から変わっていないため、このレッドシグナルが発生しました。これをグリーンシグナルにするように変更します。最小のプログラムは・・・

Player.h
CPlayer::GoNext()
{
   SetPosition(100, 100, 100);
}

です。突っ込みいれたいでしょうけども、お待ち下さい。まずはこれでコンパイル→実行します。シグナルオールグリーンです。少なくともテストは通ります。

 次に、CPlayerTestクラスのtestSetDirection関数でさらにテスト内容を付け足します。何をするかというと、もう一度角度と位置を設定して、assertEqurals関数に別の期待値を入れるんです。例えば次のような感じです。

PlayerTest.h
#include "Player.h"

class CPlayerTest
{
public:
   CPlayerTest();
   virtual ~CPlayerTest();

   void testSetDirection(){
      CPlayer Payer1;
      // とりあえず適当な期待値を与えます
     Player1.SetDirection(45.0);           // 45度
     Player1.GoNext();                       // 次の位置へ進め
     assertEquals(100, Player1.GetPosition().x);
     assertEquals(100, Player1.GetPosition().y);
     assertEquals(100, Player1.GetPosition().z);

     // 再度角度切り替え
     Player1.SetDirection(-45.0);           // -45度
     Player1.GoNext();                       // 次の位置へ進め
     assertEquals(50, Player1.GetPosition().x);
     assertEquals(50, Player1.GetPosition().y);
     assertEquals(50, Player1.GetPosition().z);
   }
};

そうすると、次のような実行時エラーが返ります。

エラー内容
□□ Good !
□□ Good !
□□ Good !
■■ CPlayer.h: 期待値は 50 でしたが、引数は 100 となり異なっています。
■■ CPlayer.h: 期待値は 50 でしたが、引数は 100 となり異なっています。
■■ CPlayer.h: 期待値は 50 でしたが、引数は 100 となり異なっています。

 先ほどは大丈夫だったのに、今度はエラーが出ました。これは「角度を変更するとエラーになってしまう」という新たな問題があることが発覚したわけです。同じテストを違うパラメータで行い検証することを「クロスチェック」といい、多方面の分野で扱われる大切なテスト方法です。ここでは、そのテスト方法をあえてわざとらしく行ったわけです(笑)。どうしてこんな遠回りをするかというと、テストコードを正しく書くためです。テストコードに抜かりが無い事が、TDDではとても重要になります。

 先ほどのエラーの原因はどこにあるか?いわずもがな、GoNext関数です。そろそろToDoリスト3段目に沿った内容をここに入れましょう。

 「角度を設定すると、その方向に1単位進む」わけですから、まず1単位を決めます。ここは、とりあえず勝手なマクロ定数を与えます。マクロ定数の名前はPLAYER_MOVEUNITとでもしましょう。メンバ変数を作りたいですか?それは、少なくともこのToDoリストにはありませんからNoです!

 指定の角度に対して1単位距離進むためには、三角関数での計算が必要です。各成分に対してそれを適用します。・・・ん?あ、角度が1つ足りません(^^;。3次元ですから、少なくとも2回回転する必要がありますね。お〜これはいかんいかん(自分で言うのもなんですが、わざとらしい・・・)

 こういうエラーも良く起こります。その場合、ToDoリストを見直し変更します。

ToDoリスト
Player1の初期取得位置は(0, 0, 0)となる
Player1の位置変更後、変更した位置が取得できる
・ Player1は最初X軸方向に向いていて、指定された方向(自分を原点としたz軸およびy軸の2軸回転角度で方向が決定)に行けと命令されると1単位分位置を変える
・ Player1は打てと命令されると弾を打つ

 3段目がずいぶん長くなりましたね。ToDoリストは短い方が対処しやすくなります。そこで、3段目を2つに分けます。まだ解決していないリストについては、こういう改変はOKです。

ToDoリスト
Player1の初期取得位置は(0, 0, 0)となる
Player1の位置変更後、変更した位置が取得できる
・ Player1は最初X軸方向を向いていて、自分を原点としたz軸およびy軸の2軸回転角度が与えられると、次に進むべき1単位分の差分位置を算出できる
・ Player1は「行け」と命令されると1単位分位置を変える
・ Player1は打てと命令されると弾を打つ

どうでしょう。3段目は結局、「角度を2つ与えると、次の位置を予測計算する」というテスト内容に変更になりました。4段目は「予測計算された位置へ移動する」というテストです。

 ちょっとコーヒーブレイク・・・


A テストコード改変

 新しいToDoリスト3段目のテストコードを書きます。そろそろ変更単位を大きくし、文章も簡略化していきます(この章、結局極短にはなりませんでした・・・)。
 まずは先ほどまでのテストはリセットして全部消してしまいます。次に、testSetDirection関数内のテストコードで、角度設定関数を2つの角度を設定するPlayer1.SetDirectionZY(45, 45)に変更します。これは、Z軸およびY軸回転による方向決定をする関数です。このままだと、比較テストができないので、ToDoリスト4段目も同時にテストしてしまいます。GoNext関数を呼び位置を1単位分更新させて、その位置を取得しassertEquals関数で比較します(期待値は適当に256とでも設定)。
 書き込んだらコンパイル。はい、「そんな関数はありません」。では、CPlayerクラスを変更します。

Player.h
CPlayer::SetDirectionZY(double z_axis_direct, double y_axis_direct)
{
   // 角度の保持
   m_dZAxisDirect = z_axis_direct;
   m_dYAxisDirect = y_axis_direct;

   // 指定の角度から次に進む位置を計算
   double Rad = 3.1415926535/180;
   m_dNext_X = PLAYER_MOVEUNIT
                    * cos(Rad * m_dZAxisDirect) * cos(Rad * m_dYAxisDirect);
   m_dNext_Y = PLAYER_MOVEUNIT
                    * sin(Rad * m_dZAxisDirect) * cos(Rad * m_dYAxisDirect);
   m_dNext_Z = PLAYER_MOVEUNIT
                    * sin(Rad * m_dYAxisDirect);
}

コンパイルするとエラーは3つに大別されます。「PLAYER_MOVEUNITなど知らん」、「sin, cosて何?」、「そんなメンバ変数は知らない」。これらはCPlayerクラス内にマクロ定数の追加、math.hの導入、5つのメンバ変数を加える事で解決できます。諸々の訂正を終えると、とりあえずコンパイルエラーは無くなります。

ところで、上の角度計算の意味を説明します。まずは下の模式図をご覧下さい。

これはZ軸が画面の手前から奥へ突き抜けていると考えてXY平面を真下から見ている図です。Z軸を左回りに回転させると、XY軸は図の赤い軸(X'Y'軸)へ移動します。この時、X軸の先端にくっついている星は、X'軸の先端に移動するわけです。次に、その状態からY'軸を左回り(進行方向から見て)にまわすと、星はX'軸上を原点に向かって進んでいくかのように見えるはずです(頭の中でのイメージが大切ですよ)。90度回せば原点に行くのは想像できますよね。ということは、今見えているX'軸はcos(Y軸回転)分短くなっていくように見えるわけです。Z軸を回転させた時点で、星のX座標はcos(Z軸回転)。それがさらにcos(Y軸回転)分比例して短くなるのですから、Z軸Y軸が回転し終わった後の星のX座標は

   m_dNext_X = cos(Z軸回転) * cos(Y軸回転)

となるわけです。同様に考えれば、星のY座標、Z座標も簡単なイメージで計算できます。


 これで、コンパイラテストは通ったので、計算が期待通りに行われているかをテストします。そのために、まずはこのまま実行してレッドシグナルを発生させます。

エラー内容
□□ Good !
□□ Good !
□□ Good !
■■ CPlayer.h: 期待値は 256 でしたが、引数は 100 となり異なっています。
■■ CPlayer.h: 期待値は 256 でしたが、引数は 100 となり異なっています。
■■ CPlayer.h: 期待値は 256 でしたが、引数は 100 となり異なっています。

まず、このテスト結果ではZ軸45度、Y軸45度、移動距離2(=PLAYER_MOVEUNIT)なので1回GoNext関数が呼ばれると、(x, y, z) = (1.00, 1.00, 1.41)に行くことを期待しています。その計算がうまくできていません。これは、GoNext関数が間違っている可能性があります。見てみましょう。

Player.h
CPlayer::GoNext()
{
   SetPosition(100, 100, 100);
}

さっきのままでしたね・・・。では、ここを正しくします。ここでやることは、ToDoリスト5段目の「1単位分位置を変える」ですから、現在の位置に移動分を足してやればいいわけです。

Player.h
CPlayer::GoNext()
{
   SetPosition(
      m_Position.x + m_dNext_X,
      m_Position.y + m_dNext_Y,
      m_Position.z + m_dNext_Z
   );
}

 コンパイルしてみましょう。すると、エラーは無いのですが、警告が出ました。

エラー内容
warning C4244: 'argument' : 'double' から 'int' に変換しました。データが失われているかもしれません。

 これは、SetPosition関数の引数が「int型」なのに対して、代入しようとしていた値がdoublr型だったために警告されてしまいました。3次元で動くことを想定しているわけですから、double型に変更することにしましょう。

 ・・・同じ警告が止まりません。ただ、今度はSetPosition無いのm_Positionへの値の代入部分で出ています。なるほど、m_Positionのメンバ変数は「int型」で宣言していました。これも変更を要しますね。TDDで開発をすると、このように「何を変更すればよいか」を確認しながら開発を進められます。「変更したときの影響は・・・」と心配になるかもしれませんが、それはもうテストプログラムができているのですから、大丈夫なんです。

 変更後コンパイルすると警告が18箇所にも増えてしまいました。見るとassertEqualsで同様の暗黙の型変換の警告になっています。assertEquals関数の引数は両方とも「int型」でした。さて、これもdouble型に変えるかというと、そうしない方が良いです。それよりも、double型も比較できるようにしてしまいましょう。そのために、CTestCaseクラスでassertEquals関数をdouble型引数でオーバーロードします。

 コンパイルすると、次のようなエラーが出ました。

エラー内容
error C2666: 'assertEquals' : 2 のオーバーロード関数があいまいです。

これは、assertEquals(0, Player1.GetPosition().x)の「0」が整数なのか浮動小数点なのかが曖昧なために発生しました。そこで、0を0.0に変更すると、エラーの数は減ります。同様にして、該当する曖昧部分をすべて浮動小数点に変更すると・・・コンパイルエラーはなくなりました!

 さ、では実行テストです。

エラー内容
■■ 期待値は 256.000000 でしたが、引数は 1.000000 となり異なっています。
■■ 期待値は 256.000000 でしたが、引数は 1.000000 となり異なっています。
■■ 期待値は 256.000000 でしたが、引数は 1.414214 となり異なっています。

綺麗なレッドシグナルです。最後にassertEquals関数の期待値部分を正しい値にして、これをグリーンシグナルに変えます。次のように期待値を変更してみました。

PlayerTest.h
assertEquals("CPlayer.h", 1.0, Player1.GetPosition().x);             // 変更位置取得
assertEquals("CPlayer.h", 1.0, Player1.GetPosition().y);             // 変更位置取得
assertEquals("CPlayer.h", 1.41421356, Player1.GetPosition().z);  // 変更位置取得

結果以下の実行エラーが出てしまいました。

エラー内容
■■ 期待値は 1.000000 でしたが、引数は 1.000000 となり異なっています。
■■ 期待値は 1.000000 でしたが、引数は 1.000000 となり異なっています。
■■ 期待値は 1.414214 でしたが、引数は 1.414214 となり異なっています。

結果は一緒のように見えるのに、実行テストはグリーンシグナルになりません。もう薄々感づいていると思うのですが、double型は桁落ち等の有効桁数問題があるため、厳密に等しい計算はできないのです。桁落ち問題をどうするかは、どれだけ厳密性を必要とするかによります。ゲームの場合はそれほど厳密な精度は必要ありません。よって、期待値と値の差の絶対値が1.0-E06以下くらいであれば等しいとしてしまいましょう。
 assertEquation(double型)関数内を次のように変えます。

PlayerTest.h
#define DOUBLE_PERCISION

void assertEquals(const char* targetclass, double expect, double val){
   char c[100];
   double Dev = fabs(expect - val);
   if(Dev >= pow(0.1, DOUBLE_PERCISION)){
      sprintf(c, "■■ %s: 期待値は %f でしたが、引数は %f となり異なっています。\n",targetclass, expect, val);
   OutputDebugString(c);
   }
   else
   {
      sprintf(c, "□□ %s: Good !\n", targetclass);
      OutputDebugString(c);
   }
}

乱暴かもしれませんが、科学計算では考えていないので、これでも実用に耐えます。実際この変更後、実行時エラーは無くなります。

 最後にクロスチェックです。testSetDirection関数内に、別の角度を設定してテストします。今回は1回目の移動後、さらにZ軸を-259度、Y軸を+123度回転させて見ます(角度にあまり意味はありません)。Excelで計算した期待値は、それぞれ、

  (x, y, z) = (1.207844, -0.0692650, 3.091555)

です。まずはレッドシグナル。

エラー内容
■■ 期待値は 50.000000 でしたが、引数は 1.207844 となり異なっています。
■■ 期待値は 50.000000 でしたが、引数は -0.069265 となり異なっています。
■■ 期待値は 50.000000 でしたが、引数は 3.091555 となり異なっています。

そして、期待値にそれぞれの正しい値を入れて、グリーンシグナルにします。上の移動後の座標を入れると、めでたくグリーンシグナルとなりました。、


B 弾を打つ!

 ちょとした大仕事を終えた感じがして、終わりかと思ってしまいましたが、そういえばToDoリストにはもう1つ残っていました。

ToDoリスト
Player1の初期取得位置は(0, 0, 0)となる
Player1の位置変更後、変更した位置が取得できる
Player1は最初X軸方向を向いていて、自分を原点としたz軸およびy軸の2軸回転角度が与えられると、次に進むべき1単位分の差分位置を算出できる
Player1は「行け」と命令されると1単位分位置を変える
Player1は打てと命令されると弾を打つ

 これはTDDはしっかりやりますが、説明は簡潔に参ります。「弾を打つ」という行為は何かというと、弾オブジェクトを作成して、それに飛んでいってもらうということではないでしょうか?よって、ToDoリストをそう書き換えます。

ToDoリスト
Player1は打てと命令されると弾を作る。
打たれた弾は飛ぶ

 このToDoリストを見る限り、また結構な手間になりそうです。これについては、次の章で改めて作って行きます。