<戻る

STGつくろー
その6 ちょっと難問当たり判定をYAGNIでこなそう!


@ 当たり判定のToDoリストは

 当たり判定はSTGにかかせません。そして、この調整こそがSTGの面白さを左右する大要素の1つでもあるのです。よって、当たり判定を作る時には少し慎重な設計が必要になるかと考えます。

 とは言うものの、考え出すときりが無い当たり判定のお話。これは難しい設計を立てようと気構えている証拠で、YAGNIの原則から足がはみ出ています。気楽にToDoリストを考えましょう。目的は「当たった?」ということだけなので、そういうToDoリストにしてしまいます。

ToDoリスト
・ キャラクタは他のキャラクタが当たったら「当たった!」と言う

今回はこの1つだけです。



@ キャラクタは他のキャラクタが当たったら「当たった!」と言う

 当たったか否かというテストコードからスタートです。当たり判定関係のテストを行うCCollisionTestクラスをまず作ります。ちなみに、Collisionというのは「衝突」という意味です。2つのキャラクタを用意して、一方のキャラクタの位置とボリュームを取得する関数を追加します。

CollisionTest.h
void testCollision()
{
   CCharacter Chara1, Chara2;
   Chara1.SetPosition(10, 10, 10);
   Chara1.SetVolume(5.0);      // ボリュームを設定
  Chara2.SetVolume(8.0);      // ボリュームを設定

   // 当たり判定(はずれ)
   Chara2.SetPosition(30, 30, 30);
   assertEquals(FALSE, Chara1.Collision(&Chara2));   // 当たり判定チェック

   // 当たり判定(当たり)
   Chara2.SetPosition(23, 10, 10);
   assertEquals(TRUE, Chara1.Collision(&Chara2));   // 当たり判定チェック
}

 キャラクタの大きさを決めるSetVolume関数の引数はどうしようかと思いましたが、今はとりあえず浮動小数点1つだけにしておきます。将来の拡張は・・・おおっとYAGNI、ヤグニ!
 コンパイルしてエラーを出したら、それを解消して行きます。SetVolume関数は、キャラクタオブジェクトの大きさを設定します。これは暗黙に「ボリュームが球だ」と定義しています。CCharacterクラスにSetVolume関数を追加して、ボリュームを表すm_dVolumeを追加します。

 Collision関数で当たったかどうかを判定します。球の場合、この判定計算はとっても簡単です。引数の位置と球の中心点との距離が、設定されている半径よりも短ければ「当たり!」、遠ければ「はずれ・・・」です。距離Dの計算は以下の式で表されます。

   D = sqrt( (x-m_Position.x)^2 + (y-m_Position.y)^2 + (z-m_Position.z)^2 )

踏まえますと、

Character.cpp
bool CCharacter::Collision(Character* chara)
{
   // 距離を計算
   double Dev_x = chara->GetPosition().x-m_Position.x;
   double Dev_y = chara->GetPosition().y-m_Position.y;
   double Dev_z = chara->GetPosition().z-m_Position.z;
   double D = sqrt( Dev_x*Dev_x + Dev_y*Dev_y + Dev_z+Dev_z );

   // 当たり判定
   // 自分と相手の体の大きさの和が距離より短ければ当たり
   if(D <= m_dVolume + chara->GetVolume();
      return TRUE;

   return FALSE;
}

となります。これでコンパイルするとGetVolume関数が無いというエラーが出ますので、さらに追加すればエラーは無くなります。実行テストは次のようになります。

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

いい感じでレッドシグナルです。期待値を正しい値にすれば、グリーンシグナルとなります。非常にシンプルではありますが、当たり判定はひとまずこれで行きます。



A ちょっと大きなリファクタリング

 この章の内容で、大きなリファクタリングをする部分は無いと思うのですが、気になるといえば距離の計算部分。差分を計算する部分で「chara->GetPosition()」を3回呼んでいます。あえて変更するなら、関数の呼び出しを1回に押さえるために、POSITIONオブジェクトをローカルで生成して、そこに値を代入し、後はローカル変数だけで済ませてしまうというのが可能です。

Character.cpp
bool CCharacter::Collision(Character* chara)
{
   // 距離を計算
   POSITION Pos = chara->GetPosition();
   double Dev_x = Pos.x-m_Position.x;
   double Dev_y = Pos.y-m_Position.y;
   double Dev_z = Pos.z-m_Position.z;
   double D = sqrt( Dev_x*Dev_x + Dev_y*Dev_y + Dev_z+Dev_z );

   ...
}

 これでどれだけの最適化になっているかは不明ですが、この関数は異常な回数呼ばれるはずなので、ちょっとした改変が影響したりします。


 さて、この章までの全体を通してのリファクタリングは無いものか探してみます。リファクタリングは積極的に行うのです。良く見ると、CEnemyクラスに、

  ・ Clash(Bullet*)関数  (当たった弾によって耐久力を減らす)
  ・ DidBroken()関数  (耐久力がなくなって壊されているかを判定)
  ・ SetHardiness()関数  (耐久力を設定)
  ・ GetHardiness()関数  (耐久力を取得)

というのが設定されています。これらは、良く考えると何も敵だけではなくキャラクタ全体について言えますよね。よって、これら関数はすべてCCharacterクラスへ移動します。こういう抽象化はリファクタリングで頻繁に行われます。

 この抽象化を行うとエラーがたくさん出来てきます。まず気にするのがこのエラー。

エラー内容(CCharacter.h内)
error C2061: 構文エラー : 識別子 'CBullet' がシンタックスエラーを起こしました。

これはCCharacterクラスに移したClash関数の引数CBulletをこのクラスが知らないために起きたエラーです。もちろんBullet.hをインクルードすれば消えます。ただ、ここでふっと考えます。

敵が弾を知っているのはわかるが、キャラクタが弾を知っているってのは変だな・・・

キャラクタはとても抽象的なもので、「弾」という特定の物を知るべきでは無いでしょう。そこで思い出すのが、CShootingCharacterクラス。CCharacterの子クラスで「弾を打つキャラクタ」として具体化したものです。弾を打つ事を知っているなら問題ありません。よって、Clash関数はこのクラスへ再度移動です。同様のことが先ほど移動した他の関数にも言えるかもしれません。よって、ここではCShootingCharacterクラスを大幅るファクタリングすることに決定!
(「衝突判定」と「耐久力が減る」というのは、確かに次元が違う話です。RPGの木とかは、衝突はしますが別になくなったりしませんものね。)

 コンパイルエラーの回避で難しいところはありません。ただ、実行テストは必ず行います。今回の場合は、リファクタリング後をすべてのテストがオールグリーンでした。

 現在までの簡単なクラス図を示します。


 「なんだ普通じゃん」と思うかもしれませんが、このクラス図の設計図ははじめ一切ありませんでした。テスト主導型開発をしながらリファクタリングを繰り返していくうちに、自然とこの形に収まったわけです。しかも、これらのクラス間で設定されている関数の動作は、テストコードによって保証されています。設計図を最初に書いて、あとからテストを行おうとしたときに、このつながりのどこをどうテストすればよいか、結構考えると思います。もうテストが終わっている。この安心感は、プログラムがさらに巨大に膨れあがるほど大きくなります。

 さて、これで「動いて、弾を発射して、当たって、耐久力を減らして壊れる」という基本的な部分が大雑把に出来ました。次は、弾の発射をもう少し細かく考えます。