ホーム < ゲームつくろー! < オブジェクト指向設計編 <CS2:自機から弾を発射する(2)
その6 CS2:自機から弾を発射する(2)
前章のその5(CS2:自機から弾を発射する(1))で作成した自機から弾を発射するクラス図は、OCP(Open-Closed Principle:開放閉鎖原理)をあまり満たしていないことがわかりました。そこでこの章では前章の続きとして、クラス図をOCPを満たすように改良します。尚、前章をご覧になっていない方は、まずそちらをじっくり見ていただいて、この章にお入り下さい。
とりあえず、前章で作成したクラス図を示します。
* 図をクリックすると大判が見られます。
動作の説明をします。CUserInterfaceオブジェクトから「弾を撃て」というメッセージがCOperateMngオブジェクトに飛びます。すると、COperateMngオブジェクト内部ではCBulletオブジェクトが生成されます。続けてCOperationObjオブジェクトから現在の位置座標を取得し、弾にその座標を教えた後、弾をCBulletUpdaterオブジェクトに登録します。CBulletUpdater::Move関数を呼び出すと、内部でCBullet::Update関数が呼ばれ、弾の位置が1フレーム分更新されます。弾にはライフタイムがあるので、指定の時間(20フレーム)が来ると、CBulletUpdaterは登録された弾を自動的に解除する、という仕組みになっています。言葉上ではうまく動く気はするのですが、実はあまり良いクラス設計にはなっていないのです。
@ COperateMngクラスとCBulletクラスの分離
前章で問題になったことの1つは、弾の生成と初期化を担当するCOperateMngクラスとCBulletクラスとが派生関係にあるにもかかわらず強く関連しすぎているということでした。COperateMngクラスは、プレイヤーの弾発射の命令を受けて、内部で弾を生成し、それをCBulletUpdaterに渡します。この内部生成はShoot関数内で行うつもりでしたが、現状ではCBulletオブジェクトしか作れません。弾は非常に変化に富む物で、新しい弾を使用するには、COperateMngクラス自体を派生しなければ対処できない状態にあるわけです。
これはまず、COperateMngクラスの仕事を見直す必要があります。このクラスの役目は、「上部の指示に従い弾と自機を具体的に操作する」事です。つまり、このクラスは操作専門なのです。にもかかわらず、今の設計は「弾の生成」も担当してしまっています。ここに仕事の不一致があるわけです。本来このクラスは、「どんな弾かは知らないが、とにかくCBulletクラスに属するオブジェクトに自機の位置を教えてCBulletUdataerに渡す」という役目を担っています。それが「どんな弾を作るのか」と考えてしまっている。そこがまずいわけです。そこで、弾の生成部分を分離してしまいます。
生成に関する部分を分離するために、弾を生成するクラスであるCBulletFactoryを新設します。このクラスは外部からの「弾を生成せよ」という命令を聞き、弾を1つ生成します。COperateMngクラスはCBulletFactoryオブジェクトを外部から与えられることにより、様々な弾について「自機と弾を結びつける」という共通の振る舞いに集中できます。ちなみにこの「弾を生成するクラス」の振る舞いはデザインパターンの代表的な1つで「Factory Method」と呼ばれています。
作成される弾の種類はCBulletFactoryの派生クラスに委ねられます。もし弾に対して特別な初期化を行いたい場合は、CBulletFactoryの派生クラスにおいて特別なメンバ関数を公開して、COperateMngクラス以外で初期化が行われることになるでしょう。つまり、もはやCOperateMngクラスは弾の初期化も担当しないということになります。仕事が限定してずいぶんと楽になりました。
A CUserInterfaceクラスの抽象化
CUserInterfaceはプレイヤーとシステムの仲介をするバウンダリクラス(Boundary Class:境界クラス)です。一般にバウンダリクラスは変化が激しいクラスだとされています。幸いな事にゲームというのはごく限られた固定された操作系(幾つかのボタンと方向キー)でするものですが、「1つの操作系が状況によって異なる命令を出す」という点が変化を促しています。固定されたボタンというのはありがたいもので、インターフェイスクラス化に繋がります。
今はインターフェイスについて細かなクラスを作る時ではありません。よって、ボタンを押す、離すという関数を持ったボタンクラス(CGameButton)を基底クラスに挙げ、現在のCUserInterfaceクラスは「CTestButton」というテスト用のクラスとして格下げしてしまいましょう。
クラス図が大きくなり過ぎて来たので、関連する部分のみを示しています。
B 出力統一のための出力対象オブジェクトの自動登録
実は本章のメインがここです。これまでのクラス図は出力について考えていませんでした。それは仕様書がそうなっていたので当然なのですが、テストをするにあたって出力がやはり必要です。出力について考えつく問題が2つあります。1つは出力対象となるオブジェクトをどうやって引っ張ってきて描画するのか、もう1つは本番の出力に影響を与えないようにするにはどうしたら良いかです。
当然のことではありますが、出力は統一するべきです。出力対象オブジェクトがあちこちで自分勝手に出力されては、とても描画のバランスを保てません。これは、出力対象となるオブジェクトすべてをどこかに登録しなければならないことを示しています。しかし、出力対象となるオブジェクトはいつどこで生成されるかわかりません。それをシステムが全て拾っていては、描画対象オブジェクト全てをシステムが管理することになってしまいます。それは、出来なくは無いですがとても大変な作業です。システム自ら描画対象オブジェクトを拾いに行かなくても済むにはどうしたら良いか…これは大分悩みました。そして、ある1つのアイデアにたどり着きました。
最初にぱっと思いついた大胆な解決策の1つは「グローバル関数を使う」という案でした。描画対象オブジェクトがグローバル領域に作成されたグローバル関数に自らを登録するわけです。それだと確かに楽です。しかし、これは完全にオブジェクト指向の概念を壊してしまっています。登録すると言うことは、一時的に保存をするということですから、保存するリスト変数、そして登録削除それぞれのグローバル関数が必要です。これでは全くダメですね。
次に考えたのは、描画対象のオブジェクトが、共通の「描画管理オブジェクト」を受け渡していく、というたらい回し案でした。デザインパターンのVisitorに近い動きです。これは、悪くない気もするのですが、「たらい回し」というのが非常に面倒なんです。例えば、CBulletオブジェクトは出力対象なので描画管理オブジェクトを渡さなければなりませんが、それを生成するCBulletFactoryは出力するオブジェクトではありません。しかし、描画管理オブジェクトをバケツリレーのように渡していかなければならないため、CBulletFactoryクラスもやはりこの描画管理オブジェクトを知っておかなければなりません。となるとCBulletFactoryクラスを生成するクラスも描画管理オブジェクトを知っていなければならないし…と上へ上へと伝播してしまいます。結局、一番下層のクラスのために最上層のクラスまでも迷惑することになってしまいます。極端な話をすれば、全てのクラスが描画管理クラスを知っていなければならなくなります。必要の無い変数を皆抱える。これは、もはやオブジェクト指向ではありません。
描画対象オブジェクトをシステムが取りに行くのは厳しい、描画担当グローバル関数は全然ダメ、描画してくれるオブジェクトをたらい回すのは無理。となるとどうするか?たどり着いた答えは、「描画対象オブジェクトよ、お前はもう俺を知っているはずだ」という案でした。
特定のクラスだけが知るグローバルな変数があります。それは「クラス属性」と呼ばれています。クラス属性とはある基底クラスに属する派生クラスが共通にアクセスできるstaticなメンバ変数の事で、「クラス限定グローバル変数」と呼べるものです。このstaticメンバ変数に出力管理オブジェクトへのポインタを渡しておくと、オブジェクトはそのポインタを通して登録関数を呼び出し、自身を登録することが出来るようになります。描画対象オブジェクトの誰かがそのstatic変数に有効な描画管理オブジェクトへのポインタを登録さえすれば、他の描画対象オブジェクトは「自分が誰に利用されるかは知らないけれども」そこにいる描画担当者に自らの描画を依頼することができるようになるわけです。
これを使うと「出力管理オブジェクトへの登録の自動化」が出来てしまいます。ここでは、これを使うことにしました。
具体的な実装は次の通りです。
まず、出力対象基底オブジェクトクラス(CDrawObj)という基底クラスを作成します。CBulletクラスやCOperationObjクラスはそこから派生させます。CDrawObjクラスには、次のようなメンバがあります。
CDrawObj メンバ 意味 関数 protected CDrawObj() プロテクトコンストラクタ。内部では描画管理オブジェクトに自身を登録する public virtual void Draw() = 0 描画する(純粋仮想関数) public static void SetDrawerPtr(CDrawer* ptr) 描画管理オブジェクトへのポインタを登録する 変数 private static CDrawer* m_pDrawer 描画管理オブジェクトへのクラス内グローバルポインタ。しかしCDrawObjしかアクセス権を持っていない。
続いて描画管理基底クラスであるCDrawerクラスも示しておきます。
CDrawer メンバ 意味 関数 public virtual void Draw() 登録オブジェクトを描画する public void Regist(CDrawObj* ptr) 描画オブジェクトポインタをリストに登録する 変数 private list<CDrawObj*> m_ObjList 描画オブジェクトリスト
描画対象オブジェクトの根幹であるCDrawObjクラスのメンバについて説明します。公開関数で純粋仮想関数であるDraw関数では描画を行います。すべての描画対象オブジェクトはこの関数をオーバーライドして、独自の描画を定義しなければなりません。static宣言されているSetDrawerPtr関数は、描画管理クラスであるCDrawerクラスのオブジェクトポインタを登録します。ここに登録された描画管理オブジェクトが、後で全体の描画を行います。非公開関数であるCDrawObjコンストラクタでは、CDrawerオブジェクトに自分自身を登録します(後で示すコードをご覧下さい)。
次に登録されたすべての描画対象オブジェクトに描画命令を与えるCDrawerクラスを説明します。このクラスには2つの公開関数があります。Draw関数はリストに登録された描画オブジェクトを登録順に全て描画します。Regist関数が描画オブジェクトを登録する関数です。CDrawObjはデフォルトでこの関数を呼び出し登録作業を行うので、通常は呼び出されることがありません(本当ならCDrawObjクラスの派生クラスだけにこの関数を公開したいのですが、C++にはその機能がありません)。
以上をそのまま実装するとこうなります。
class CDrawObj; // 事前宣言
////////////////////////////////
// 描画管理クラス
////////
class CDrawer
{
protected:
list<CDrawObj*> m_DrawList; // 描画オブジェクトリスト
public:
CDrawer(){};
virtual void Draw(); // 登録されたオブジェクトを描画する
void Regist( CDrawObj* ptr ); // 描画オブジェクトを登録する
};
/////////////////////////////////////
// 描画基底クラス
////////
class CDrawObj
{
private:
static CDrawer *m_pDrawer; // 描画管理オブジェクト(クラス属性)
protected:
// コンストラクタ(プロテクト宣言)
CDrawObj(){
if(m_pDrawer)
m_pDrawer->Regist( this ); // 生成と同時に自分自身を登録
}
public:
// 描画関数(純粋仮想関数)
virtual void Draw()=0;
// 描画管理オブジェクト登録(static定義)
static void SetDrawerPtr( CDrawer *ptr ){
m_pDrawer = ptr;
}
};
CDrawer::Draw関数は次のように実装します。
void CDrawer::Draw()
{
list<CDrawObj*>::iterator it = m_DrawList.begin();
for(;it!=m_DrawList.end(); it++){
if((*it))
(*it)->Draw();
}
}
CDrawerクラス及びCDrawObjクラスは、次のように使います。
int _tmain(int argc, _TCHAR* argv[])
{
// 描画の初期化
CDrawer *pDrawer = new CDrawer; // 描画管理オブジェクト生成
CDrawObj::SetDrawerPtr( pDrawer ); // 描画管理オブジェクトを登録
// 描画オブジェクト作成
CBullet *pBullet = new CBullet;
COperationObj *pOpeObj = new COperationObj;
// 描画
pDrawer->Draw();
return 0;
}
最初に描画管理オブジェクトを生成します。次にそれをCDrawObjクラスが持つクラス属性として登録するのですが、その方法がちょっと独特です。上のプログラムでCDrawObj::SetDrawerPtr関数を直接呼び出しています。とても奇妙かもしれませんが、ちゃんとstatic宣言されたメンバ変数に描画管理オブジェクトが登録されます。ですから、わざわざCDrawObjを生成したりすることはありません(と言うよりも純粋仮想関数なので生成できません)。後は描画対象となるオブジェクト(CDrawObjクラスの派生クラス)を生成するだけです。生成したと同時にCDrawObjコンストラクタがpDrawerに自分自身を自動登録します。この段階で、すでに描画準備は整います。最後にDrawerのDraw描画関数を呼び出すことで、登録されたオブジェクト(pBullet、pOpeObj)がその順番に描画されます。
最初の2命令は一番最初に一度だけ宣言しておけば良い部分で、いずれ空気のような存在になってしまいます。残りの部分を見て頂くと、これはオブジェクトを生成して、描画関数(CDrawer::Draw関数)を呼んでいるだけです。この作業、「生成」→「描画」という通常と全く同じプロセスを踏んでいますよね。「描画担当者に登録」という作業が無いにもかかわらず、CDrawerがどこにあって、どこから呼んでも描画は実行されます。オブジェクト指向の観点から見ても、生成した描画対象オブジェクトの登録作業は完全に隠蔽されています。
CDrawObjもCDrawerもまだ未完成感はあります。これについて、今は例ですからこれ以上言及しないことにします。
C (暫定)クラス図完成版
これでOCPを満足させるためのおおよその改良が終わりました。まだ色々と改良したいところはあります。抽象度もそれほど完全ではありません。ただ、これ以上やりますと疲れてしまいますので(笑)、このくらいに収めておこうと思います。たかが弾を撃つというだけでも、OCPを考えた時にはここまでする必要があるわけで、オブジェクト指向というのは本当に大変です。
暫定完成版のクラス図を示しておきます(UMLに準拠していない部分がありますので悪しからず)。
さて、ここまで読まれた方、お疲れ様でした。もう少し楽をしたいもんですが、実際はもっと大変です(^-^;。さて次回CS3では「シーンの管理」をオブジェクト指向してみたいと思いますが、そのために幾つかの説明をはさみます。