ラクス Advent Calendar 2017の22日目です。
昨日は@kanoariさんのエンジニアではない人向けのWebサービス技術の基礎知識でした。
はじめに
みなさんはじめまして! TVゲームが好きでIT業界に入ったFlatMountainです。
ちなみに、今年ラクスに新卒で入社したピッカピカの一年生です。
いま私はオブジェクト指向におけるデザインパターンの勉強をしています。
そこで、今回はデザインパターンの中から
- State
- Flyweight
という二つのパターンについて、C++でのゲームプログラミングという実例を用いて説明します。
この二つのパターンを選んだ理由は、それらがとても実践的なパターンだと感じたからです。
デザインパターンって取っつきにくい
と思っている方が、この記事を通じて少しでも親しみを覚えていただければ嬉しいです。
注意!:コード例について
コード例を参考にされる方は、以下の点にお気をつけください。
- クラスの宣言、定義をcppファイルのみに記述しています。
表記の都合上そういう形をとりましたが、本来はヘッダとcppに分けるべきです。 - コード例の中でスマートポインタやSTLを利用しています。
今回は解説を省略しますので、詳しく知りたい方は記事最下部の参考リンクをご確認ください。
本題:ゲームプログラミングで触れるデザインパターン
Stateパターン
特徴
クラス内で状態に応じた処理を行いたいときに利用します。
オブジェクト指向の考え方がわかりやすいパターンではないでしょうか?
実例
例えば、シーンに応じた処理を行いたいとしましょう。シーンごとの処理というと、
- タイトル画面
プレイヤーの入力を待って、ゲーム画面へ遷移する。 - ゲーム画面
キャラクターを動かす
といったものがありますね。
まず、単純な実装を考えてみましょう。
以下のコードは、ゲームの全体処理を管理するクラスです。
// シーンを表す定数.
#define SCENE_ID_TITLE 0
#define SCENE_ID_GAME 1
// ゲームの実処理を管理する.
class GammeManager{
private:
// 今がどのシーンか示す値.
int nowSceneCode;
public:
// コンストラクタ、初期画面をタイトルにセットする.
GameManager(){
nowSceneCode = SCENE_ID_TITLE;
};
// ゲームの実処理を実行する.
int run(){
//メインループ
while(true){
// シーンに応じた処理を行う.
switch(nowSceneCode){
case SCENE_ID_TITLE:{
// タイトル中の処理,入力を待ってゲーム画面に遷移する。
...
break;
}
case SCENE_ID_GAME:{
// ゲーム中の処理,キャラクターを動かしたりする.
...
break;
}
}
}
};
};
という感じになると思います。
GameManagerはゲームの実処理を管理します、メイン関数から*gameManager->run()*と呼び出すイメージですね。
この実装の悪いところは何でしょう? ちょっと考えてみると、
シーンが増えるごとに条件分岐とシーン内部の処理を、GameManagerに追加する
必要があるということが分かりますね。
原因は、
- ゲームの実処理を管理すること
- シーン内部の処理を行うこと
の二つがくっついてしまったことです。
そこで、シーン内での処理を行う基底クラスを定義して、GameManagerに持たせてみましょう。
// シーン内での処理を行う基底クラス.
class BaseScene{
public:
// 継承先で実装される関数.
virtual void update() = 0;
};
// ゲームの実処理を管理する.
class GameManager{
private:
// 今がどのシーンか示すクラス.
std::unique_ptr<BaseScene> nowScene;
public:
// コンストラクタ、初期画面をタイトルにセットする.
GameManager(){
nowScene = std::make_unique<TitleScene>();
};
int run(){
// メインループ.
while(true){
// シーンに応じた処理を行う.
nowScene->update();
}
};
};
またBaseSceneを継承して、各シーン内の具体的な処理を行うクラスも作成します。
// タイトルシーン内での処理を行うクラス.
class TitleScene : public BaseScene{
public:
void update()override{
// タイトル中の処理、入力を待ってゲーム画面へ遷移する.
...
};
};
// ゲームシーン内での処理を行うクラス.
class GameScene : public BaseScene{
public:
void update()override{
// ゲーム中の処理、キャラクターを動かしたりする.
...
};
};
どうでしょう、GameManager内でシーンの処理を行う記述が、
nowScene->update();
と、たった一行になってしまいました。
各シーンの具体的な処理は、*TitleScene::update()やGameScene::update()*が担当していますね。
Stateパターンとは
オブジェクトの内部状態として基底クラスをもち、振る舞いは継承したクラスで解決する。
これがStateパターンです。
このパターンのよいところは、
状態を保持しているクラスは、その実装を知らなくても状態に応じて何か処理をする。
ことです。
実例で言えば、GameManagerは*nowScene->update()内で何が行われているか知りません。
でも、とりあえずその関数が「シーン内で何か処理を行う」関数であること事は分かります。
なので、GameManagerからシーン処理を呼び出す際はnowScene->update()*を呼ぶだけなのですね。
このようなプログラムの動き方を見たことはないでしょうか?
そうです、まさしくポリモーフィズムですね!
Stateパターンは、ポリモーフィズムの分かり易い実例だと言えます。
また、シーンを増やす際はBaseSceneを継承したクラスを作成するだけなので、GameManagerへの追記はそれほど増えません。
例えば、エンディング画面の処理を増やそうと思えば、
class EndingScene ;public BaseScene{
public:
void update()override{
// エンディング画面の処理
...
}
}
とクラスを定義し、*GameManager::run()*内のメインループで
nowScene.reset(new EndingScene())
というようにnowSceneを変更するだけです。
Flyweightパターン
特徴
同じリソースを使いまわそうというパターンです。
メモリの使用量を抑えるために利用されます。
実例
例えば、以下のような敵クラスと画像クラスを考えます。
なお、画像関連処理の際にDXライブラリを利用しています。
// 敵基底クラス.
class BaseEnemy{
protected:
// 画像クラス.
std::unique_ptr<Texture> texture;
public:
// 更新処理、実装は継承先クラスが行う.
virtual void update() = 0;
// 描画処理、実装は継承先クラスが行う.
virtual void draw() = 0;
// 以下getterやsetterを記述.
...
}
// 弱い敵.
class WeakEnemy : public BaseEnemy{
public:
// 更新処理の実装.
void update()override{
// 移動したりする.
...
};
// 描画処理の実装.
void draw()override{
...
};
}
// 強い敵.
class StrongEnemy : public BaseEnemy{
public:
// 更新処理の実装.
void update()override{
// 移動したりする.
...
};
// 描画処理の実装.
void draw()override{
...
};
}
// 画像クラス、画像の幅や高さも知りたいのでクラス化.
class Texture{
private:
// 画像ハンドル.
int handle;
// 画像の幅.
int width;
// 画像の高さ.
int height;
public:
// コンストラクタ、読み込んだ画像ハンドルを設定する.
// またハンドルから、画像の幅と高さを取得する.
Texture(int handle){
this->handle = handle;
getGraphSize(handle, &width, &height)
};
// 以下getterを記述.
...
}
具体的には以下のように描画処理を行います。
// ゲームシーン内での処理を行うクラス.
class GameScene : public BaseScene{
private:
// 登場した敵のリスト.
std::list<std::unique_ptr<BaseEnemy>> enemyList;
public:
void update()override{
// ゲーム中の処理、キャラクターを動かしたりする.
...
// 弱い敵が登場した、敵クラスを作成しリストに登録する.
auto newEnemy = std::make_unique<WeakEnemy>();
this->enemyList.push_back(newEnemy);
// 画像を読み込む.
int handle = LoadGraph("image/weak_enemy.png");
// 画像クラスを作成し、敵クラスに設定する.
auto newTexture = std::make_unique<Texture>(handle);
newEnemy->setTexture(newTexture);
};
// 描画処理.
void draw()override(){
// すべての敵を描画する.
for(auto enemy : enemyList){
enemy->draw();
}
};
};
メインループ内で、*GameScene::draw()*が呼び出されるイメージですね。
この実装の悪いところは、
どんな敵が現れても、必ず画像を読み込む処理を行っている
ところにあります。 もし、弱い敵が10000体くらい同時に登場していたら、メモリが圧迫されてゲームが動かなくなるかもしれませんね。
そこで、同じ画像を読み込むときは読み込みをしないように変更します。
// 敵基底クラス.
class BaseEnemy{
protected:
// 画像クラス、プールしたものと共有するためshared_ptrを利用する.
std::shared_ptr<Texture> texture;
public:
// 更新処理、実装は継承先クラスが行う.
virtual void update() = 0;
// 描画処理、実装は継承先クラスが行う.
virtual void draw() = 0;
// 以下getterやsetterを記述.
...
}
// 画像の読み込み、管理を行うクラス.
class TextureFactory{
private:
// 画像をプールするコンテナ、(K,V) = (fileName, Texture).
std::map<std::string, std::shared_ptr<Texture>> textureContainer;
// 画像クラスを作成.
std::shared_ptr<Texture> createTexture(std::string fileName){
// 画像の読込処理.
int handle = LoadGraph(fileName.c_str());
// 画像クラスの作成.
auto newTexture = std::make_shared<Texture>(handle);
// 作成した画像クラスを返す.
return newTexture;
};
public:
// 画像クラスを取得する.
std::shared_ptr<Texture> getTexture(std::string fileName){
// ファイル名で画像クラスを検索.
auto it = textureContainer.find(fileName);
// コンテナ内にあれば、その画像クラスを返す.
if(it != textureContainer.end()){
return it->second();
}
// コンテナ内に無ければ、新しく画像クラスを作成してコンテナに登録.
auto newTexture = createTexture(fileName);
textureContainer.insert(fileName, newTexture);
// 作成した画像クラスを返す.
return newTexture;
}
}
// ゲームシーン内での処理を行うクラス.
class GameScene : public BaseScene{
private:
// 登場した敵のリスト.
std::list<std::unique_ptr<BaseEnemy>> enemyList;
// 画像の読み込み、管理を行うクラス.
std::unique_ptr<TextureFactory> textureFactory;
public:
void update()override{
// ゲーム中の処理、キャラクターを動かしたりする.
...
// 弱い敵が登場した、敵クラスを作成しリストに登録する.
auto newEnemy = std::make_unique<WeakEnemy>();
this->enemyList.push_back(newEnemy);
// 画像クラスを取得し、敵クラスに設定する.
newEnemy->setTexture(textureFactory->getTexture("image/weak_enemy.png"));
};
// 描画処理.
void draw()override(){
// すべての敵を描画する.
for(auto enemy : enemyList){
enemy->draw();
}
};
TextureFactoryを通せば、一度読み込んだ画像クラスを再利用できるようになりましたね。
クラスの関係は以下のようになります。(重要なところを抜き出しています)
Flyweightパターンとは
オブジェクトが既に作られていればそれを利用し、まだ作られていなければ作る。
これが、Flyweightパターンです。
このパターンを使う際には気をつけることがあります。それは、
オブジェクトをプールしたら、それを変えるべきではない
ところです。
なぜかというと、プールした中身を変えると共有していたところがすべて変わってしまうからです。
例えば弱い敵の画像がいきなり強い敵のものになったら、プレイヤーはびっくりしますよね?
なので、Flyweightパターンは不変なオブジェクトに利用されることが多いです。
実例では、
- Textureのプロパティを読み込み専用にする
- textureContainerをprivateメンバにする
という二つの対策で解決しています。
こうすることでプールした画像クラスが外部で変更されないようにしています。
最後に
いかがでしたか?
State,Flyweightパターンってこんな感じなんだと、分かっていただけたのではないでしょうか。
最後に言いたいのはオブジェクト指向のデザインパターンとは、あくまでコードの書き方だということです。
デザインパターンを利用することで、
- 見やすく
- 変更に強い
つまり、良いコードが書けるようになります。
私もまだまだ新米エンジニアなので、読みづらいコードを書くことが多いです。
でも、そんなときはデザインパターンを思い出して、良いコードにできないか考えています。
今後はもっとパターンの引出しを増やして、よりよいコードを書けるようにしていくつもりです。
この記事を読んで自分が「良くないコード」を書いていると思った皆さん、ぜひこの機会にデザインパターンについて学んでみてはいかがでしょうか?
以上で今回の記事は終わりです、拙い記事でしたが最後まで閲覧していただきありがとうございました!
明日は@kengo_kuwaharaさんの記事です、どうぞご期待ください!