<戻る

STGつくろー!
NO15 自機のロールで見る遷移図の実装方法


@ ロールの仕様

 今回のSTGでは、自機を左右に動かした時に少し傾く、つまり「ロール」をさせたいと考えています。また、しばらく傾け続けた後切り返すと、「ぐる!」っと1回転させる演出を加えます。そのための仕様を考えてみました。

自機仕様
○ ロール
 ・ 左右に移動するときに、2/3度単位でロール。逆回しの時には、その時の角度から開始。これを「通常旋回」と呼ぶことにする。
 ・ 通常旋回は+20度及び-20度でストップ。このストップした状態を「ホールド」、その角度を「ホールド角度」と呼ぶことにする。
 ・ ホールドが1秒以上続いた段階で、逆方向に入れると12度単位で1回転し、入れた方向のホールド角度で固定。たとえば、+20度でホールドしている機体を右に旋回させると、最大-400度(1回転+40度)まで旋回してホールド。このホールドするまでの間を「急旋回」と呼ぶ。
 ・ 急旋回の最中はホールド角度に達するまで12度単位で高速旋回。
 ・ 急旋回してホールド状態になり、1秒以内に切り返しが起きた場合は、通常旋回。
 ・ 急旋回してホールド状態になり、1秒以上立てば、また急旋回状態。
 ・ 通常旋回および急旋回中にニュートラル(コントローラを入れていない)状態になった場合、3度単位でロール角度が今の角度から0度に戻る。

 大変細かい仕様で、文字だとイメージしにくいと思います。今回のロールルールを図示すると次のようになります。

1単位レベルでの非常に細かい操作が連続します。例えば、左に12単位入ると+8度傾きますが、その段階で右に6単位入れると4度戻って+4度になります。また、ホールド角度に達した後、1秒(60単位)以上右に入れて左に10単位入れると-20+12*10=100度になり、そこでニュートラルを10単位続けると+70度になります。

 このロールルールは大変複雑です。適当に作ると、強烈なスパゲッティプログラムになってしまいます。少しでも見通しの良いプログラムにするため、今回は「遷移図」と「ミニタスク」を用いることにします。



A ロールの遷移図とミニタスク

 今回のロールルールのように、「ある条件がそろうと次の状態に移る」という類は、状態間のつながりを表す遷移図を書いていくと、そのつながりがわかりやすくなります。今回の自機のロールの仕様の遷移図は次のようになります。


 遷移図を目で追っていくと、意味がはっきりします。まず、通常状態ニュートラル(Normal Neutral)は通常状態入力にしか遷移しません。通常状態からはニュートラルに戻るか、そのままホールド状態に移行するかのどちらかです。ホールドしてからも逆方向にレバーを入れれば、それは通常状態に戻りますし、ニュートラルにしても角度は戻ります。ホールドした状態からニュートラルはありますが、ニュートラルからホールドはありません。ホールド状態が1秒以上続くと、急旋回入力(Quick Input)に移行します。同じ方向に入れ続けると、そのままホールド角度に達します。ホールド角度に達しない状況では、入力かニュートラルかのいずれかになります。ニュートラルをしばらく続けると角度は0度になり、通常状態ニュートラルに戻ります。
 この遷移図で、すべての状態が説明されています。

 さて問題はこの実装。色々なアプローチがあると思いますが、今回はそれぞれの節をオブジェクトとして扱い、各オブジェクトがつながって小さな仕事「ミニタスク」を行う仕組みにしてみます。そのためのクラスCRollStrategyを定義します。また、各節(Node)の親クラスをCRollNodeとし、これらのつながりで共通する振る舞いを定義します。

 上のクラスを少し意識しながら、今回もTDDとYAGNIは欠かさず、少しずつ実装していきましょう。



B 最も簡単なロール

 一番簡単なロールは「ぼーっとしていて何もロールしない」です。ぼーっとするというのはニュートラルであるということですから、それをToDoリストにすると次のようになります。

ToDoリスト
・ 通常時ニュートラルで、何もしないと0度のまま

 このテストコードを示します。

CRollStrategyTest.h
void testInitNeutral()
{
   // 通常時ニュートラルで、何もしないと0度のまま
   sp<CRollNormNeut> spRollNormNeut( new CRollNormNeut );

   assertEquals( 0.0, spRollNormNeut->Roll() );
}

 最も簡単な実装は次のとおりです。

RollNormNeut.cpp
double CRollNormNeut::Roll()
{
   return 0.0;
}

 これでテストはすべて通ります。リファクタリングはこの段階ではしようが無いですね。



C ちょっとだけ左右ロール

 次に、左右にちょっとだけロールさせてみましょう。ToDoリストです。

ToDoリスト
・ 左に1単位ロールすると、+2/3度回転
・ 右に1単位ロールすると、-2/3度回転

 テストでは新しいクラスを作ります。

CRollStrategyTest.h
void testNorm_Left1()
{
   // 左に1単位ロールすると、+2/3度回転
   sp<CRollNormIpt> spRollNormIpt( new CRollNormIpt );

   assertEquals( 2.0/3, spRollNormIpt->Roll(ROLL_LEFT) );
}

void testNorm_Right1()
{
   // 右に1単位ロールすると、-2/3度回転
   sp<CRollNormIpt> spRollNormIpt( new CRollNormIpt );

   assertEquals( -2.0/3, spRollNormIpt->Roll(ROLL_RIGHT) );
}

 こんな感じでしょうか。Roll関数の引数に回転方向を表すマクロ定数ROLL_LEFTおよびROLL_RIGHTを渡します。CRollNormIptクラスの実装を見てみましょう。

CRollNormIpt.cpp
double RollNormIpt::Roll(int flag)
{
   if(flag == ROLL_LEFT)
      return 2.0/3;

   return -2.0/3;
}

 これでこのToDoテストは通ります。なんかアドホック(場当たり的)な感じがしますが、今はこれでよいのではないでしょうか。



D ニュートラルからインプットへ

 ここがまず第1の山です。通常時ニュートラル状態から左右へ入力した状態へ遷移させます。まずはToDoリストを作成しましょう。

ToDoリスト
・ ニュートラル状態から左へ1単位入力すると通常状態入力オブジェクトを取得
・ ニュートラル状態から右へ1単位入力すると通常状態入力オブジェクトを取得

 このテストプログラムは少し考えてしまいます。ニュートラルオブジェクト(CRollNormNeut)に左回転を入力すると、戻ってくるのは次の角度です。同時に通常状態入力オブジェクトを取得するには、引数にオブジェクトへのポインタを渡すか、取得関数を新たに設定しなければなりません。ちょっとだけ先読みします。取得関数を設定すると、それをちゃんと呼ばないとうまく機能しません。一方、引数に参照渡しすると、クライアントに強制化できます。ここでは、Roll関数の引数に渡すことにしましょう。

 テストプログラムは次のようになります。

CRollStrategyTest.h
void testNormNeutToLeft()
{
   // ニュートラル状態から左へ1単位入力すると通常状態入力オブジェクトを取得
   sp<CRollNormNeut> spRollNormNeut( new CRollNormNeut);
   sp<CRollNode> spRollNode;

   // ニュートラル状態に対して左1単位入力
   spRollNormNeut->Roll(ROLL_LEFT, spRollNode);

   // 角度を取得してテスト
   // 1単位左に入力しているので2/3度
   assertEquals( 2.0/3.0, spRollNode->GetAngle() );
}

 右バージョンもすぐにできますね。これをコンパイルするとエラーが沢山できてきます。まずCRollNodeという遷移図の節の親クラスを新たに定義しました。この親クラスでRoll関数と角度を取得する関数GetAngle関数を定義します。

RollNode.h
class CRollNode
{
protected:
   double m_dAngle;

public:
   virtual double RollNode::Roll(int flag, sp<CRollNode> &next) = 0;
   double GetAngle(){ return m_dAngle; };
};

 Roll関数は純粋仮想関数として定義します。
 コンパイルエラーを修正するには、CRollNormNeutクラスとCNormIptクラスをCRollNodeクラスの派生クラスにし、Roll関数をオーバーロードさせます。その実装部分は同じです。また、これまでのテストに対しても引数の数が違うというコンパイルエラーが出ます。直すのは簡単です。修正すれば、コンパイルエラーはきれいになくなります。

この段階で実行テストを行うと、testNormNeutToLeft関数によるテストがレッドシグナルになります。それも、メモリ保護違反です。これは当然で、テスト内のCRollNormNeutオブジェクトのRoll関数が返す次の節オブジェクトが存在しないからです。早速、CRollNormNeut::Roll関数内でオブジェクトを返すようにしましょう。

RollNormNeut.cpp
double CRollNormNeut::Roll(int flag, sp<CRollNode> &next)
{
   if(flag != ROLL_NEUTRAL){
      // 通常状態入力オブジェクトを生成
      next.SetPtr( new CRollNormIpt );
      m_dAngle = next->Roll(flag, next);
      return m_dAngle;
   }

   m_dAngle = 0.0;
   return m_dAngle;
}

 flagがニュートラルでない場合、通常入力オブジェクトを生成してそのRoll関数に振る舞いを移譲します。メンバ変数m_dAngleに結果を格納して、関数自体の戻り値にします。
 この修正後に実行テストをしたのですが、やっぱりレッドシグナルです。これはCRollNormIpt::Roll関数の変更をしていないからですね。

RollNormIpt.cpp
double RollNormIpt::Roll(int flag, sp<CRollNode> &next)
{
   if(flag == ROLL_LEFT){
      m_dAngle = 2.0/3;
      return m_dAngle;
   }

   m_dAngle = -2.0/3;
   return m_dAngle;
}

 これでグリーンシグナルです。


E ニュートラル→インプット→ニュートラル

 次は少し発展させて、ニュートラル状態から左右に入力し、またニュートラルに戻します。まずToDoリストは次のようになります。

ToDoリスト
・ ニュートラルから左へ1単位入力しニュートラルに1単位戻すと0度
・ ニュートラルから右へ1単位入力しニュートラルに1単位戻すと0度
・ ニュートラルから左へ6単位入力しニュートラルに1単位戻すと1度
・ ニュートラルから右へ6単位入力しニュートラルに1単位戻すと-1度

 3段目のテストコードだけを示します。

CRollStrategyTest.h
void testNormNeut_Left6_Neut1()
{
   // ニュートラルから左へ6単位入力しニュートラルに1単位戻すと1度
   sp<CRollNormNeut> spRollNormNeut( new CRollNormNeut);
   sp<CRollNode> spRollNode;
   spRollNode = spRollNormNeut;

   // ニュートラル状態に対して左6単位入力
   for(int i=0; i<6; i++)
      spRollNode->Roll(ROLL_LEFT, spRollNode);

   // ニュートラルで1単位戻す
      spRollNode->Roll(ROLL_NEUTRAL, spRollNode);

   // 角度を取得してテスト
   // 2/3 * 6 - 3 = 1 度
   assertEquals( 1.0, spRollNode->GetAngle() );
}

 これをコンパイルすると、あっさりと通ります。しかし、実行テストはレッドシグナルとなります。期待値は1度なのですが、戻ってきたのは0.6666度(=2/3度)です。これをグリーンシグナルにしましょう。

 ステップ実行するとすぐにわかるのですが、ニュートラル状態から左に6単位入れると、今の実装ではいつも2/3度が返ってきます。まずはここを修正です。仕様では、左1単位ごとに2/3度ずつ増えるので、

RollNormIpt.cpp
double RollNormIpt::Roll(int flag, sp<CRollNode> &next)
{
   if(flag == ROLL_LEFT){
      m_dAngle += 2.0/3;
      return m_dAngle;
   }

   m_dAngle -= 2.0/3;
   return m_dAngle;
}

これで再実行・・・しかし、まだレッドシグナルです。戻り値は3.3333度です。それもそのはず。上のコード内にニュートラルが入ってきた時の処理が無いからです。

RollNormIpt.cpp
double CRollNormIpt::Roll(int flag, sp<CRollNode> &next)
{
   switch(flag)
   {
      case ROLL_LEFT:
        m_dAngle += 2.0/3;
        return m_dAngle;
     break;
      case ROLL_RIGHT:
        m_dAngle -= 2.0/3;
        return m_dAngle;
     break;
      case ROLL_NEUTRAL:
         next.SetPtr( new CRollNormNeut );
         m_dAngle = next->Roll(flag, next);
         return m_dAngle;
  }

   return 0.0;  //
}

 これで実行すると・・・まだレッドシグナル。なかなか大変。今度は戻り値が0になってしまいました。ステップ実行してみると、CRollNromNeut::Roll関数の中で3度戻らなければならないのに0度を返す実装になっていました。ここを変更します。

RollNormNeut.cpp
double CRollNormNeut::Roll(int flag, sp<CRollNode> &next)
{
   if(flag != ROLL_NEUTRAL){
      // 通常状態入力オブジェクトを生成
      next.SetPtr( new CRollNormIpt );
      m_dAngle = next->Roll(flag, next);
      return m_dAngle;
   }

   // ニュートラル時の実装
   m_dAngle = next->GetAngle();
   if(m_dAngle > 0)
      return m_dAngle-=3.0;

   return m_dAngle+= 3.0;
}

 引数のnextオブジェクトから角度をもらい、その角度がプラスだったら-3度、マイナスだったら+3度しています。これで実行すると・・・え〜まだダメ?大分嫌になってきました(笑)。今度は戻り値が3度になってしまいました。ステップ実行してチェックすると、面白いことになっています。

 入力オブジェクトからニュートラルに戻るとき、CRollNormIpt関数内でエラーが起きていました。その部分を抽出します。

RollNormIpt.cpp
double CRollNormIpt::Roll(int flag, sp<CRollNode> &next)
{
   switch(flag)
   {
      case ROLL_NEUTRAL:
         next.SetPtr( new CRollNormNeut );
         m_dAngle = next->Roll(flag, next);
         return m_dAngle;
  }
}

 caseの下で、nextに新しいCRollNormNeutオブジェクトを生成し、そのポインタをスマートポインタに格納しています。その段階で、nextには真新しいニュートラルオブジェクトが格納されます。で、そのRoll関数を呼び出し、2番目の引数に「自分自身」を渡しています(これ自身は悪いことではありません)。CRollNormNeut::Roll関数内では、

RollNormNeut.cpp
double CRollNormNeut::Roll(int flag, sp<CRollNode> &next)
{
   // ニュートラル時の実装
   m_dAngle = next->GetAngle();
   if(m_dAngle > 0)
      return m_dAngle-=3.0;

   return m_dAngle+= 3.0;
}

 と自分自身から角度を得ています。真新しいオブジェクトの初期値は0度なので、if文は無視され、結局+3.0度が格納されます。

 つまり、真新しいオブジェクトの初期値を変える機構が必要になるようです。これは他の節オブジェクトでも同様でしょうから、CRollNodeクラスのコンストラクタを変えます。

RollNode.cpp
CRollNode::CRollNode(double initangle)
{
   m_dAngle = initangle;
}

 派生クラスのコンストラクタでも、初期角度を渡すようにします。

RollNormNeut.cpp
CRollNormNeut::CRollNormNeut(double initangle)
: CRollNode(initangle)
{
}

 さ!これでコンパイルすると・・・あちこちでエラーが出ますが、すべて生成時に初期化して下さいという内容です。すべてでちゃんと初期化を行うと、ほぼグリーンシグナルです!ただ、一番最初のテストがレッドシグナルになっていました。

 一番最初のテストは、ニュートラルのままにしておくものでしたが、期待値が0だったのに対して戻り値が+3度でした。これは、CRollNormNeut::Roll関数に原因があります。

RollNormNeut.cpp
double CRollNormNeut::Roll(int flag, sp<CRollNode> &next)
{
   // 左右の実装はそのままで・・・
   // ...

   // ニュートラル時の実装
   m_dAngle = next->GetAngle();
   if(m_dAngle > 0)
      return m_dAngle-=3.0;

   return m_dAngle+= 3.0;
}

 ニュートラルのままにする時に、引数の角度の符号で増減を決定していますが、たとえば0度に対しては加算するのではなくて、そのままにして欲しいわけです。つまり、増減をする必要があるかないか判断しなければならないわけです。次のように修正しました。

RollNormNeut.cpp
double CRollNormNeut::Roll(int flag, sp<CRollNode> &next)
{
   if(flag != ROLL_NEUTRAL){
      // 通常状態入力オブジェクトを生成
      next.SetPtr( new CRollNormIpt );
      m_dAngle = next->Roll(flag, next);
      return m_dAngle;
   }

   // ニュートラル時の実装
   m_dAngle = next->GetAngle();
   if(m_dAngle - 3.0 > 0)         // プラス角度が変わらない
         return m_dAngle-=3.0;
   else if(m_dAngle + 3.0 < 0)    // マイナス角度が変わらない
         return m_dAngle+=3.0;

   // 符号が変わるので0度でとめる
   return m_dAngle = 0.0;
}

 少し整理した条件文です。m_dAngle-3.0がゼロより大きい場合、問答無用で-3度します。そうじゃない場合、m_dAngle+3.0がゼロを下回っているなら、やはり問答無用で+3度します。それ以外だと増減で符号が変わる状態なので、0度でとめるようにします。

 ふ〜〜。これで、やっと、やっと、やっと、やっと!オールグリーンになります。本気で疲れました。



F 疲れたけどリファクタリングもしなくちゃね

 TDDはレッドシグナル、グリーンシグナル、リファクタリングの3拍子が大切です。ちゃんとリファクタリングもしておきましょう。・・・と言っても、全体の実装で気になるところは特にありません。ただ、プログラム中に「3.0」のような定数が直に書いてあるのはあまりよくはありません。これはマクロ定数にしてしまいます。

 この続きは次の章で進めましょう。