#はじめに
HAL Advent Calendar 2018の25日目を担当しています。HAL大阪ゲーム学科のロードです
この記事はある程度C++を理解してる人向けに作ってます。(継承、Virtual、templateなど)
プロジェクト全体をGitHubにて公開しています。ほとんどここにもソース書いてますが一応
ここ
##追記
結構見られている記事なので追記しました。
#コンポーネント指向とは
Wikipediaによると。
個々のソフトウェアコンポーネントは、ソフトウェアパッケージだったり、Webサービスだったり、ウェブリソースだったり、相互に関連する機能(とデータ)の集合をカプセル化したモジュールだったりする。
らしいです。コンポーネントの概念を知りたい方は他のQiitaのページなりWikiなり読んでください。
#なぜコンポーネント指向を使うのか
正直コンポーネントの概要はどうでも良いですがゲームの中身をこれで書こうという話がしたかったのです。
なぜ薦めるのかというと大きく言うと2つあって
ゲームやシーンによってゲームループのメインのコードを何一つ変えなくても動く
ワシら少なくなったら足していくだけや!で組める
からです。(上のほうはオブジェクト指向でも出来ますが・・・
ここでピンと来た人はあまり読む意味ない記事になるかもしれないです。
こんなことを言ってもわけわからないと思うので実際のコード例でみてみましょう。
#実際のコード例
殴り書き。オブジェクト指向。コンポーネント指向。の3ステップでやっていきます。
今回はソースを出来るだけ短くするためにコンソールで動かしています。
作る物はこれも分かりやすくて良く例に出されるシューティングぽいものでやっていきます。
コンソールへの入出力を楽にするためにこれらのクラスを使います。
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <conio.h>
#define SCREE_NLENGTH 20
//コンソールに出力するためのバッファ
class ScreenBuffer
{
public:
union {
char buffer[SCREE_NLENGTH * (SCREE_NLENGTH + 1) + 1];
char buffer2[SCREE_NLENGTH][SCREE_NLENGTH + 1];
};
ScreenBuffer()
{
Clear();
}
void Clear()
{
//Bufferをスペースでクリア
for (int i = 0; i < SCREE_NLENGTH * (SCREE_NLENGTH + 1); i++)
{
buffer[i] = ' ';
}
//行の終わりに改行入れる
for (int i = 0; i < SCREE_NLENGTH; i++)
buffer2[i][SCREE_NLENGTH] = '\n';
//全ての終わりにNULL文字
buffer[SCREE_NLENGTH * (SCREE_NLENGTH + 1)] = '\0';
}
};
class InputData
{
static char Buffer;
public:
static void Update()
{
if (_kbhit()) {
Buffer = _getch();
}
}
static bool KeyCheck(char key)
{
if (Buffer == key)
return true;
return false;
}
};
extern ScreenBuffer g_ScreenBuffer;
##ただの殴り書き
殴り書きといってもクラス化はしてますが汚いです
class Enemy
{
public:
static std::list<Enemy*> List;
int x, y;
Enemy()
{
List.push_back(this);
}
void Update() {}
void Draw()
{
g_ScreenBuffer.buffer2[x][y] = 'E';
}
};
std::list<Enemy*> Enemy::List;
class Bullet
{
public:
int x, y;
void Update()
{
x--;
auto buff = Enemy::List;
for (auto e : buff)
{
if (e->x == x && e->y == y)
Enemy::List.remove(e);
}
}
void Draw()
{
g_ScreenBuffer.buffer2[x][y] = 'b';
}
};
class Player
{
int x, y;
//球のリスト
std::list<Bullet*> BulletList;
public:
Player()
{
x = 5; y = 8;
}
void Draw()
{
g_ScreenBuffer.buffer2[x][y] = 'p';
//球の描画を呼ぶ
for (auto b : BulletList)
b->Draw();
}
void Update()
{
//移動
if (InputData::KeyCheck('d') && y < SCREE_NLENGTH - 1)
y++;
if (InputData::KeyCheck('a') && y > 0)
y--;
if (InputData::KeyCheck('s') && x < SCREE_NLENGTH - 1)
x++;
if (InputData::KeyCheck('w') && x > 0)
x--;
//球のupdateを呼ぶ
for (auto b : BulletList)
b->Update();
//球発射
if (InputData::KeyCheck(' '))
{
BulletList.push_back(new Bullet());
BulletList.back()->y = y;
BulletList.back()->x = x - 1;
}
//画面から消えた球を消す
//イテレーション回してるリストの中身変えるとおかしくなるので
std::list<Bullet*> buff = BulletList;
for (auto b : buff)
{
if (b->x < 0)
BulletList.remove(b);
}
}
};
int main()
{
Player player;
Enemy enemy[10];
for (int i = 0; i < 10; i++)
{
enemy[i].x = 1;
enemy[i].y = i;
}
while (!InputData::KeyCheck('p'))
{
//画面の初期化
system("cls");
g_ScreenBuffer.Clear();
InputData::Update();
//実際の処理
{
player.Update();
for (auto e : Enemy::List)
e->Update();
player.Draw();
for (auto e : Enemy::List)
e->Draw();
}
//Buffer表示
printf("%s", g_ScreenBuffer.buffer);
Sleep(100);
}
return 0;
}
注目したいのが3点あって
一つ目がMainLoopの中の実際の処理の部分。このままいくと敵の弾が増えたりアイテムが増えたりするたびに変えていく必要が出てきそうなのは分かってもらえると思います。
二つ目がEnemyとBulletの依存関係がこの後面倒になっていきそう。もちろんMainLoopの中にBulletのUpdateのこと書けばEnemyにList持たせなくても大丈夫だしクラス間の依存関係が薄くなって面倒にはならなさそうだけどさらにMainLoopの処理が間延びします。
三つ目がBulletの管理です。この構造になるとプレイヤーが管理するしか無いのでPlayerとBulletの依存関係もかなり強いと言えるでしょう。自分の設計に問題があるかもしれませんがBulletの依存度が高い。見にくい。とても綺麗とは言いづらい形に・・・
##オブジェクト指向
オブジェクト指向といっても今回かかわってくるのはポリモーフィズムです。
ポリモーフィズムを活用するとMainLoopがすっきりします。
クラス図です。 オブジェクト指向なのでオブジェクトのベースクラスを継承して全てのクラスを作っています。
class Object
{
public:
Object() {}
virtual ~Object() {}
int x, y;
virtual void Update() {}
virtual void Draw() {}
};
//オブジェクトのリストを定義
std::list<Object*> g_ObjectList;
class Enemy : public Object
{
public:
void Update() {}
void Draw()
{
g_ScreenBuffer.buffer2[x][y] = 'E';
}
};
class Bullet : public Object
{
public:
void Update()
{
x--;
//画面外に消えてたら自分を消す
if (x < 0)
{
g_ObjectList.remove(this);
delete this;
return;
}
//ObjectListからEnemyを検索して当たり判定を行い削除もする。
auto buff = g_ObjectList;
for (auto obj : buff)
{
//objがEnemyの場合キャスト出来る。違うと失敗してnullptrが入る
if (dynamic_cast<Enemy*>(obj) == nullptr)
continue;
if (obj->x == x && obj->y == y) {
g_ObjectList.remove(obj);
delete obj;
}
}
}
void Draw()
{
g_ScreenBuffer.buffer2[x][y] = 'b';
}
};
class Player : public Object
{
public:
Player()
{
x = 5; y = 8;
}
void Draw()
{
g_ScreenBuffer.buffer2[x][y] = 'p';
}
void Update()
{
//移動
if (InputData::KeyCheck('d') && y < SCREE_NLENGTH - 1)
y++;
if (InputData::KeyCheck('a') && y > 0)
y--;
if (InputData::KeyCheck('s') && x < SCREE_NLENGTH - 1)
x++;
if (InputData::KeyCheck('w') && x > 0)
x--;
//球発射
if (InputData::KeyCheck(' '))
{
g_ObjectList.push_back(new Bullet());
g_ObjectList.back()->y = y;
g_ObjectList.back()->x = x - 1;
}
}
};
int main()
{
//追加
g_ObjectList.push_back(new Player());
for (int i = 0; i < 10; i++)
{
g_ObjectList.push_back(new Enemy());
g_ObjectList.back()->x = 1;
g_ObjectList.back()->y = i;
}
while (!InputData::KeyCheck('p'))
{
//画面の初期化
system("cls");
g_ScreenBuffer.Clear();
InputData::Update();
//実際の処理
//Update中にObjectListがいじられてイテレーションバグるのを回避
auto buff = g_ObjectList;
for (auto obj : buff)
obj->Update();
for (auto obj : g_ObjectList)
obj->Draw();
//Buffer表示
printf("%s", g_ScreenBuffer.buffer);
Sleep(100);
}
//追加
for (auto obj : g_ObjectList)
delete obj;
g_ObjectList.clear();
return 0;
}
注目したい点が3つあって
一つ目がMainLoopがすっきりしたこと。このような構造にしておけばオブジェクトが増えてもg_ObjectListに突っ込めばMainLoopを拡張することなくできそうなのが分かってもらえると思います。
二つ目がBulletの管理で、別に誰が管理しなくても消失も当たり判定も自分自身で行うことができます。構造としてとても分かりやすくなります。
三つ目がBulletのUpdateのなかでやっているdynamic_castで実はこいつはかなりのコストがかかるcastになるので毎回全部をcastしてみるのはちょっと処理効率的にやばい。これはどうにかすることが出来るけど今回は簡単にやるためにやっているので真似しないで
##コンポーネント指向
最初から全ての処理が明確になっていればオブジェクト指向の作りでまったく問題ないです。
しかしゲームというのは開発中に仕様が2転3転してしまいます。これはしょうがないです。
仕様が変わってしまうときに対応が簡単なのがコンポーネント指向になります。
先ほどと同じ動作をする状態から仕様変更を想定してシミュレーションをしてみます。
クラス図です。今回はオブジェクトにコンポーネントのリストを持たせてコンポーネントを継承して全てのものを定義しています。
ポイントはPositionをコンポーネントとして外に出せたことです。このようにすることで全てのコンポーネントから別のコンポーネントへのアクセス手段が出来ます。さらにコンポーネントは独立しているのでPlayerがPosition使うからそれ用のPositionになったりしないわけです。
###仕様変更前
class Component
{
protected:
public:
Component() {}
virtual ~Component() {}
Object *Parent;
virtual void Start() {}
virtual void Update() {}
virtual void Draw() {}
};
class Object
{
public:
Object() {}
~Object() {
for (auto com : ComponentList)
delete com;
}
std::list<Component*> ComponentList;
void Update()
{
auto buff = ComponentList;
for (auto com : buff)
com->Update();
}
void Draw()
{
for (auto com : ComponentList)
com->Draw();
}
//オブジェクトが持っているコンポーネントを取得
template<class T>
T* GetComponent()
{
for (auto com : ComponentList) {
T* buff = dynamic_cast<T*>(com);
if (buff != nullptr)
return buff;
}
return nullptr;
}
//オブジェクトが持っているコンポーネントを追加
template<class T>
T* AddComponent()
{
T* buff = new T();
buff->Parent = this;
ComponentList.push_back(buff);
buff->Start();
return buff;
}
};
//オブジェクトのリストを定義
std::list<Object*> g_ObjectList;
//場所を示すコンポーネント
class Position : public Component
{
public:
int x, y;
};
//敵コンポーネント
class Enemy : public Component
{
Position* pos = nullptr;
public:
void Draw()
{
if (pos == nullptr)
pos = Parent->GetComponent<Position>();
g_ScreenBuffer.buffer2[pos->x][pos->y] = 'E';
}
};
//弾コンポーネント
class Bullet : public Component
{
Position* pos = nullptr;
public:
void Update()
{
if (pos == nullptr)
pos = Parent->GetComponent<Position>();
pos->x--;
//画面外に消えてたら自分を消す
if (pos->x < 0)
{
Parent->ComponentList.remove(this);
delete this;
return;
}
//ObjectListからEnemyを検索して当たり判定を行い削除もする。
auto buff = g_ObjectList;
for (auto obj : buff)
{
//全体からEnemyを探す
if (obj->GetComponent<Enemy>() == nullptr)
continue;
if (obj->GetComponent<Position>()->x == pos->x && obj->GetComponent<Position>()->y == pos->y) {
g_ObjectList.remove(obj);
delete obj;
}
}
}
void Draw()
{
if (pos == nullptr)
pos = Parent->GetComponent<Position>();
g_ScreenBuffer.buffer2[pos->x][pos->y] = 'b';
}
};
class Player : public Component
{
Position* pos = nullptr;
public:
void Start()
{
if (pos == nullptr)
pos = Parent->GetComponent<Position>();
pos->x = 5; pos->y = 8;
}
void Draw()
{
if (pos == nullptr)
pos = Parent->GetComponent<Position>();
g_ScreenBuffer.buffer2[pos->x][pos->y] = 'p';
}
void Update()
{
if (pos == nullptr)
pos = Parent->GetComponent<Position>();
//移動
if (InputData::KeyCheck('d') && pos->y < SCREE_NLENGTH - 1)
pos->y++;
if (InputData::KeyCheck('a') && pos->y > 0)
pos->y--;
if (InputData::KeyCheck('s') && pos->x < SCREE_NLENGTH - 1)
pos->x++;
if (InputData::KeyCheck('w') && pos->x > 0)
pos->x--;
//球発射
if (InputData::KeyCheck(' '))
{
Object* obj = new Object;
Position* posb = obj->AddComponent<Position>();
obj->AddComponent<Bullet>();
posb->y = pos->y;
posb->x = pos->x - 1;
g_ObjectList.push_back(obj);
}
}
};
int main()
{
//追加
Object* obj = new Object;
obj->AddComponent<Position>();
obj->AddComponent<Player>();
g_ObjectList.push_back(obj);
for (int i = 0; i < 10; i++)
{
obj = new Object;
Position* pos = obj->AddComponent<Position>();
pos->x = 1;
pos->y = i;
obj->AddComponent<Enemy>();
g_ObjectList.push_back(obj);
}
while (!InputData::KeyCheck('p'))
{
//画面の初期化
system("cls");
g_ScreenBuffer.Clear();
InputData::Update();
//実際の処理
//Update中にObjectListがいじられてイテレーションバグるのを回避
auto buff = g_ObjectList;
for (auto obj : buff)
obj->Update();
for (auto obj : g_ObjectList)
obj->Draw();
//Buffer表示
printf("%s", g_ScreenBuffer.buffer);
Sleep(100);
}
//追加
for (auto obj : g_ObjectList)
delete obj;
g_ObjectList.clear();
return 0;
}
注目点はComponentの管理方法でシーンにたくさんのObjectがありそのObjectがたくさんのコンポーネントを持っているという構造をしています。コンポーネントをコンポーネント名を指定して取得するためにGetComponentはテンプレートを使用しています。
Objectクラスの中にコンポーネントの取得。追加が組み込まれています。削除も入れたほうが良いでしょうね。
オブジェクト指向と何が違うの?と言われると階層が一つ増えただけです。ただ階層を一つ増やすことによって汎用性がかなり上がっています。
###仕様変更後
例えば今このプログラムはBulletを当てたEnemyが消えるようになっているのですが当てたEnemyをPlayerに変更するようにしたい。という仕様変更があった場合。Object指向だとEnemyが居た場所にPlayerを生成して、Enemyを削除というちょっと複雑になるのですがコンポーネント指向だとEnemyコンポーネントを削除してPlayerコンポーネントを追加するだけで済みます。ソースに起こしてみたらわかりやすいかと思います。
//弾コンポーネント
class Bullet : public Component
{
Position* pos = nullptr;
public:
void Update()
{
if (pos == nullptr)
pos = Parent->GetComponent<Position>();
pos->x--;
//画面外に消えてたら自分を消す
if (pos->x < 0)
{
Parent->ComponentList.remove(this);
delete this;
return;
}
//ObjectListからEnemyを検索して当たり判定を行い削除もする。
auto buff = g_ObjectList;
for (auto obj : buff)
{
//全体からEnemyを探す
Enemy* enemy = obj->GetComponent<Enemy>();
if (enemy == nullptr)
continue;
if (obj->GetComponent<Position>()->x == pos->x && obj->GetComponent<Position>()->y == pos->y) {
//オブジェクトを削除しない
//g_ObjectList.remove(obj);
//delete obj;
//敵コンポーネントを削除
obj->ComponentList.remove(enemy);
delete enemy;
//プレイヤーコンポーネント追加
obj->AddComponent<Player>();
}
}
}
void Draw()
{
if (pos == nullptr)
pos = Parent->GetComponent<Position>();
g_ScreenBuffer.buffer2[pos->x][pos->y] = 'b';
}
};
Bulletをこのように変更してあげるだけで敵と味方の切り替えをすることが出来ます。これが少なくなったら足してあげたら良い精神。少なくなったらというより足りなくなったらのほうがニュアンス近いかもしれないですが。。。
#まとめ
ゲームとコンポーネント指向の考えはかなり相性が良いです。あくまで実装例なので参考にして色々改変しながら使ってもらえると嬉しいです。
後dynamic_castは多分typeidのほうがマシそう。ってまとめ書いてるときに思ったのである。