ゲーム開発のためのオブジェクト指向(Observerによる描画処理とデータ処理の分割)

投稿者: | 2012年6月9日

はじめに

今回はゲームシステムの構築の核となる、Observerパターンについて解説します。

なぜObserverパターンが必要になるのか?

例えばゲームを作ろうとすると、たいてい以下のような処理を書くことになります。

// メイン実行クラス
class CMain
{
private:
	// ゲームで使う変数・フラグ…(1)
public:
	void main()
	{
		while(TRUE)
		{
			// データの入力・加工…(2)
			// データを元に描画…(3)
		}
	}
};
  • (1)は自キャラのオブジェクトであるとか、ゲームオーバーのフラグなどです。
  • (2)はキー入力の受け取りとか、弾を発射したり、敵を破壊したり、などです。
  • (3)は背景、キャラ、スコアなどの描画処理です。

ただ、ゲームの規模が大きくなるにつれて、それぞれの分量が増えていきます。そこで、(1)の部分を構造体にして取り出すとします。

// ゲームデータ構造体
struct CData
{
	// ゲームで使う変数・フラグ…(1)
};

// メイン実行クラス
class CMain
{
private:
	CData m_data;
public:
	void main()
	{
		while(TRUE)
		{
			// データの入力・加工…(2)
			// データを元に描画…(3)
		}
	}
};

データの分離です。多少スッキリしました。

さらに、(2)や(3)の処理についてもソースの分割を行ってみます。

// ゲームデータ構造体
struct CData
{
	// ゲームで使う変数・フラグ…(1)
};

// 描画クラス
class CObserver
{
public:
	void Update(CData *data)
	{
		// データを元に描画…(3)
	}
};

// データ入力・加工クラス
class CSubject
{
private:
	CObserver m_observer;
public:
	void Execute(CData *data)
	{
		// データの入力・加工…(2)
		m_observer.Update(data);
	}
};

// メイン実行クラス
class CMain
{
private:
	CData m_data;
	CSubject m_subject
public:
	void main()
	{
		while(TRUE)
		{
			m_subject.Execute(&m_data);
		}
	}
};

いきなり、ややこしくなってしまったかもしれません…。簡単に流れを説明すると、

  • CMain(ゲームループ)→CSubject(データ入力・加工)→CObserver(描画)→CMain→…

という流れになっています。

ここで強調したいのは、

  1. 「データの入力・加工」をしてから「データを元に描画」をする、という流れ
  2. この2つの処理を分離する

ということです。

これを実現するのがObserverパターンなのです。(…というかここまでで、もう解説はほとんど終了していますが…)

より汎用的なシステムを構築したい場合には、以下の文章をお読みください…。

より汎用的な使い方

複数の画面(枠)を持つゲーム

例えば、「バンゲリングベイ」(といって分かる人がいるのだろうか…)のような、全方向スクロールシューティングを作るとします。そこで、以下のようなインターフェースでゲームを作るとします。

真ん中にいるのは「ゴキブリ」でなくて「ヘリ」です。念のため…

この場合に必要となるのが、

  1. プレイヤー視点の画面
  2. スコアやステージ情報
  3. 敵機のレーダー

です。

つまり、描画の枠が「3つ」に分かれている、こととなります。

Observerパターン

クラス図は以下のようになります。

 

流れは先ほどと同じく、

  • CMain→CControllerMain(データの入力・加工)→IViewのexecute(描画)→CMain…

となります。

CMain

まず、CMainからの実行パターンです。CMainから以下のよう呼び出します。

void CMain::main()
{
	// Controllerのインスタンス生成
	CController *ctrl = new CControllerMain();
	// Viewの登録
	ctrl->addView(new CViewMain());
	ctrl->addView(new CViewScore());
	ctrl->addView(new CViewRadar());
	
	CData data;
	while(TRUE)
	{
		// Controller実行
		ctrl->execute(&data);
	}
}

ViewクラスをControllerクラスのaddView関数で登録していきます。そして、ゲームループでControllerのexecute関数を呼ぶだけです。

Viewクラス

先にViewクラスの説明をしておきます。IViewは画面描画用の基底クラスです。

IViewをinterface(純粋仮想クラス)とし、画面描画(更新)用のメンバ関数updateを定義しています。

これを派生クラスでインプリメントします。

  • CViewMain(プレイヤー視点の画面描画)
  • CViewScore(スコア画面の描画)
  • CViewRadar(レーダーの描画)

先ほどの「3つの画面」を実装しているわけですね。

Controllerクラス

CControllerのexecute関数は以下のように実装します。

void CController::execute(CData *data)
{
	inputData(data); // データの入力
	editData(data); // データの加工
	for(UINT i = 0; i < m_viewList.size(); i++)
	{
		// 描画更新
		m_viewList.at(i)->update(data);
	}
}

inputData()とeditData()は仮想関数とし、派生クラスに実装を任せます。ポイントは、登録されたViewのupdate関数を全部実行しているところですね。

この処理をViewクラスに更新を通知(Notify)しているといい、Observerパターンのキモとなる部分です。

これにより、

ControllerはViewがどんなものであるかどうか関係なしに、
(update関数を呼べば)画面の更新を要求することができる。

ということが可能となるわけです。つまり、冒頭で解説しているように、

  • 「データの入力・加工処理」と「描画処理」の分離

が可能となったわけです。

補足1

さて、ここまで読んで、「…でも、な~んか設計が複雑になってるんじゃないのー??」と思ったかもしれません。

ですが。敵の攻撃により、レーダーが破壊されたとします。そうした場合には、CViewRadarクラスをCControllerMainから外すことにより、レーダーの描画を行わないようにしたいという修正が出るかもしれません。

またはコンフィグで、スコア表示の方法を変えられるようにしたい、という要望が発生するかもしれません。

というような、「見た目の変更=仕様変更」が起きた場合、Observerパターンであれば、Viewクラスの差し替えを行うだけで、見た目の変更が簡単に行える、という利点があります。

補足2

今回の説明では省略していますが、Viewクラスには、

  • 描画を行うためのグラフィックスクラスの参照を持たせる

が必要になります。

また、Controllerクラスには、

  • 登録したViewを削除する関数

などが必要になります。

おまけ

今回は、「データの入力・加工」と「データを元に描画」という処理の分離について解説しましたが、補足としてもう少し別の使い方を解説します。

例えば、ある敵機は「画面上に10発しか弾を発射できない」とします。

その場合には、敵機をFactoryMethodとし、敵弾のインスタンスを生成するようにします。敵弾のインスタンスには、「敵機の参照」を持たせるようにします。敵機は内部にカウンタを持っており、10発の弾を発射します。そして、敵弾が画面外に出るなどして消滅したとします。

その場合には、敵弾は保持している「敵機の参照」から、発射元の敵機に「消滅した」というメッセージを送り、消滅を通知します。そうすると敵機は安心して敵弾インスタンスを削除し、弾を発射できるわけです。

といった、「部下が上司に辞職願いを通知する(?)」という使い方もできます。

参考

実は今回のパターンは、MVCというアーキテクチャを利用したパターンだったりします。

そこでMVCを理解するためのリンクを貼っておきます。