C++でコンポーネント指向設計のオブジェクト管理を実装する方法
この記事は、神戸電子専門学校 ゲーム技研部 Advent Calendar 2024の16日目の記事です。
はじめに
ゲームを作るうえで、ゲームオブジェクトをどのように表現、管理するかはとても重要です。
そして現在、最も広く使われている方法のひとつがコンポーネント指向設計です。
今回は、コンポーネント指向設計におけるオブジェクトの管理システムとコンポーネントの作り方について解説していきます。
必要な前提知識
C++の基本的な構文とポリモーフィズムが分かると読みやすいと思います。
コンポーネント指向とは
コンポーネント指向とは、オブジェクトの処理をコンポーネントとしてパーツ化し、それらを組み合わせることで挙動を作成する設計のことです。
どうしてそのようなことをする必要があるのか、次項から説明していきます。
継承で処理を共有する場合の問題点
処理を異なるクラス間で共有する最も一般的な方法として継承があります。
しかし、ゲームの規模が大きくなるにつれ、以下のような問題点が出てきます。
基底クラスの肥大化
共通処理を全て基底クラスに詰め込むと、必要のない機能まで全オブジェクトが持つことになり、クラスが肥大化します。
クラス階層の肥大化
共通処理ごとに基底クラスを分けると、必要な機能の組み合わせに応じて中間クラスを大量に作らなければならず、階層が複雑化します。
コンポーネントで処理を共有する
コンポーネント指向では、共通する処理を独立したコンポーネントとして実装し、ゲームオブジェクトはこれらを組み合わせて作ります。
このようにすることで、基底クラスを肥大化させることも、クラス階層の肥大化をさせることなく共通処理を使いまわすことができるようになります。
実装
ここからは、コンポーネントを作成し管理するための実装を紹介します。
ComponentBase
まずは、全てのコンポーネントの基底となるクラスを実装します。
持ち主(GameObject)への参照と、開始、終了、更新などの仮想関数を定義します。
class GameObject;
// コンポーネントの基底となるクラス
class ComponentBase
{
public:
// コンポーネントの開始時処理の仮想関数
// 初めて更新される直前に呼ばれる
virtual void OnStart() {}
// 通常の更新の前に更新される処理の仮想関数
virtual void OnPreUpdate() {}
// 通常の更新する処理の仮想関数
virtual void OnUpdate() {}
// 通常の更新の後に更新する処理の仮想関数
virtual void OnPostUpdate() {}
// コンポーネントが解放されるときの処理の仮想関数
virtual void OnRelease() {}
// このコンポーネントの持ち主を取得
std::weak_ptr<GameObject> GetOwner()
{
return m_spOwner;
}
private:
friend class GameObject;
// このコンポーネントの持ち主をセット
void SetOwner(std::shared_ptr<GameObject> a_spOwner)
{
m_spOwner = a_spOwner;
}
private:
// このコンポーネントの持ち主
std::shared_ptr<GameObject> m_spOwner = nullptr;
};
GameObject
GameObjectはコンポーネントの管理、更新を担うクラスです。
オブジェクトにコンポーネントを追加する関数、削除する関数、取得する関数を定義します。
極力コンポーネントの操作のみを行うようにしてください。
class GameObject
{
public:
//---------------------------------
// Component
//---------------------------------
// 引数のコンポーネントをアタッチする関数
// 引数の名前はRTTIが許される環境ならtypeidなどを使うとよい
void AddComponent(std::shared_ptr<ComponentBase> a_spComponent, std::string_view a_name)
{
// コンポーネントの持ち主としてこのオブジェクトをセット
a_spComponent->SetOwner(this);
// コンポーネントのインスタンスを名前と紐づけて保存
m_umNameToComp[a_name.data()] = a_spComponent;
}
// 引数の名前のコンポーネントを解放し削除する関数
void RemoveComponent(std::string_view a_compName)
{
auto itr = m_umNameToComp.find(a_compName.data());
// 引数の名前のコンポーネントが無効なら終了
if (itr == m_umNameToComp.end())
{
return;
}
if (itr->second == nullptr)
{
return;
}
// コンポーネントの解放処理を呼ぶ
itr->second->OnRelease();
// コンポーネントのインスタンスを削除
m_umNameToComp.erase(a_compName.data());
}
// コンポーネントを名前から取得
std::weak_ptr<ComponentBase> GetComponent(std::string_view a_name)
{
auto itr = m_umNameToComp.find(a_name.data());
if (itr == m_umNameToComp.end())
{
return std::weak_ptr<ComponentBase>();
}
return itr->second;
}
//---------------------------------
// Status
//---------------------------------
// オブジェクトの有効状態をセットする
void SetActive(bool a_isActive)
{
m_isActive = a_isActive;
}
bool CheckActive()
{
return m_isActive;
}
const std::string_view GetName()
{
return m_name;
}
private:
friend class ObjectManager;
// 名前をセットする
void SetName(std::string_view a_name)
{
m_name = a_name;
}
//---------------------------------
// 更新
//---------------------------------
// 通常の更新の前に呼ぶ処理
void PreUpdate()
{
// 初めての更新ならStartを呼ぶ
if (!m_isCalledUpdate)
{
for (auto&& spComp : m_umNameToComp)
{
spComp.second->OnStart();
}
m_isCalledUpdate = true;
}
// 全てのコンポーネントのPreUpdateを呼ぶ
for (auto&& spComp : m_umNameToComp)
{
if (spComp.second == nullptr)
{
continue;
}
spComp.second->OnPreUpdate();
}
}
// 通常の更新処理
void Update()
{
// 全てのコンポーネントのUpdateを呼ぶ
for (auto&& spComp : m_umNameToComp)
{
if (spComp.second == nullptr)
{
continue;
}
spComp.second->OnUpdate();
}
}
// 通常の更新の後に呼ぶ処理
void PostUpdate()
{
// 全てのコンポーネントのPostUpdateを呼ぶ
for (auto&& spComp : m_umNameToComp)
{
if (spComp.second == nullptr)
{
continue;
}
spComp.second->OnPostUpdate();
}
}
private:
// 既に更新が呼ばれているか
bool m_isCalledUpdate = false;
// オブジェクトが有効か
bool m_isActive = false;
// オブジェクトの名前
std::string m_name;
// コンポーネントの名前とインスタンスを紐づけて格納するコンテナ
std::unordered_map<std::string, std::shared_ptr<ComponentBase>> m_umNameToComp;
};
テンプレートの利用
コンポーネントの操作関数にテンプレートを使うと、コンポーネントの操作関数内でインスタンスの作成や型情報の利用が完結します。
これにより、操作の手続きをシンプルかつ安全にすることができます。
// コンポーネントを追加する
template<typename CompType>
std::weak_ptr<ComponentBase> AddComponent()
{
// コンポーネントのインスタンスを作成
std::shared_ptr<ComponentBase> spNewComp = std::make_shared<CompType>();
// コンポーネントの持ち主としてこのオブジェクトをセット
spNewComp->SetOwner(this);
// コンポーネントのインスタンスを名前と紐づけて保存
m_umNameToComp[typeid(CompType).name()] = spNewComp;
}
// 引数の名前のコンポーネントを解放し削除する関数
template<typename CompType>
void RemoveComponent()
{
std::string compName = typeid(CompType).name();
auto itr = m_umNameToComp.find(compName);
// 引数の名前のコンポーネントが無効なら終了
if (itr == m_umNameToComp.end())
{
return;
}
if (itr->second == nullptr)
{
return;
}
// コンポーネントの解放処理を呼ぶ
itr->second->OnRelease();
// コンポーネントのインスタンスを削除
m_umNameToComp.erase(compName);
}
// コンポーネントを名前から取得
template<typename CompType>
std::weak_ptr<ComponentBase> GetComponent()
{
auto itr = m_umNameToComp.find(typeid(CompType).name());
if (itr == m_umNameToComp.end())
{
return std::weak_ptr<ComponentBase>();
}
return itr->second;
}
さらに可変長テンプレート引数を使うことで、AddComponent内でコンポーネントのコンストラクタに引き数を渡して初期化することもできます。
// コンポーネントを追加する
// 引数からコンストラクタに値を代入することができる
template<typename CompType, typename...ArgTypes>
std::weak_ptr<ComponentBase> AddComponent(ArgTypes... a_args)
{
// コンポーネントのインスタンスを作成
std::shared_ptr<ComponentBase> spNewComp = std::make_shared<CompType>(a_args...);
// コンポーネントの持ち主としてこのオブジェクトをセット
spNewComp->SetOwner(this);
// コンポーネントのインスタンスを名前と紐づけて保存
m_umNameToComp[typeid(CompType).name()] = spNewComp;
}
ObjectManager
ObjectManagerはGameObjectを管理します。
今回はオブジェクトを名前で管理する仕組みを実装したものを紹介しますが、オブジェクトの生成と更新機能さえあれば大丈夫です。
// 全てのGameObjectを管理するクラス
class ObjectManager
{
public:
// 引数の名前のオブジェクトを作成して返す関数
// 同じ名前のオブジェクトが既に存在していた場合、名前の後ろに番号が付く
std::shared_ptr<GameObject> GenerateObject(std::string_view a_name)
{
// オブジェクトのインスタンスを作成
std::shared_ptr<GameObject> spNewObject = std::make_shared<GameObject>();
// 生成するオブジェクトの名前を求める
std::string objName = CreateObjName(a_name);
// オブジェクトに名前をセット
spNewObject->SetName(objName);
// オブジェクトを有効にする
spNewObject->SetActive(true);
// オブジェクトをリストに追加し、そのイテレータを取得
m_lObjects.emplace_back(spNewObject);
auto objItr = std::prev(m_lObjects.end());
// オブジェクトの名前とイテレータを紐づける
m_umNameToObjItr[objName] = objItr;
return spNewObject;
}
// 名前からオブジェクトを取得する
std::weak_ptr<GameObject> GetObject(std::string_view a_name)
{
auto itr = m_umNameToObjItr.find(a_name.data());
if (itr == m_umNameToObjItr.end() || *itr->second == nullptr)
{
return std::weak_ptr<GameObject>();
}
return *itr->second;
}
// 更新関数
void Update()
{
// 全てのオブジェクトを更新
PreUpdateObjects();
UpdateObjects();
PostUpdateObjects();
// 無効なオブジェクトを全て削除
RemoveUnActuveObjects();
}
private:
// 引数の名前を元に被らない名前を作成する
std::string CreateObjName(std::string_view a_baseName)
{
std::string resultName;
// 引数の名前のオブジェクトが既に存在しているか調べる
auto itr = m_umNameToObjItr.find(a_baseName.data());
bool isFirstName = false;
// 初めての名前ならその名前のまま登録
if (itr == m_umNameToObjItr.end())
{
isFirstName = true;
}
// 既に存在する名前なら、名前が重複しないように番号を付ける
for (size_t i = 1; true; ++i)
{
// 有効な名前を見つけたら終了
if (isFirstName)
{
break;
}
// 新しい名前を作成
resultName = a_baseName.data() + std::to_string(i);
itr = m_umNameToObjItr.find(resultName);
// 新しい名前のオブジェクトが存在しなければ
if (itr == m_umNameToObjItr.end())
{
isFirstName = true;
}
}
return resultName;
}
// 全てのオブジェクトの事前更新
void PreUpdateObjects()
{
for (auto&& spObjct : m_lObjects)
{
if (spObjct.get() == nullptr)
{
continue;
}
spObjct->PreUpdate();
}
}
// 全てのオブジェクトの更新
void UpdateObjects()
{
for (auto&& spObjct : m_lObjects)
{
if (spObjct.get() == nullptr)
{
continue;
}
spObjct->Update();
}
}
// 全てのオブジェクトの事後更新
void PostUpdateObjects()
{
for (auto&& spObjct : m_lObjects)
{
if (spObjct.get() == nullptr)
{
continue;
}
spObjct->PostUpdate();
}
}
// 無効なオブジェクトを全て削除する
void RemoveUnActuveObjects()
{
for (auto itr = m_lObjects.begin(); itr != m_lObjects.end();)
{
// 無効なオブジェクトを削除する
if (itr->get() == nullptr || !itr->get()->CheckActive())
{
// オブジェクトのポインタが生きていたら
if (itr->get() != nullptr)
{
// 名前とイテレータの情報を削除
m_umNameToObjItr.erase(itr->get()->GetName().data());
}
// オブジェクトのインスタンスを削除
itr = m_lObjects.erase(itr);
}
// 有効なオブジェクトなら何もせず次のオブジェクトを調べる
else
{
itr++;
}
}
}
private:
// オブジェクトの名前とイテレータを紐づけるコンテナ
std::unordered_map<std::string, std::list<std::shared_ptr<GameObject>>::iterator> m_umNameToObjItr;
// 全てのオブジェクトのインスタンスを格納するコンテナ
std::list<std::shared_ptr<GameObject>> m_lObjects;
};
コンポーネントの作り方
ここまでで、コンポーネント指向設計のオブジェクト管理の実装について紹介しました。
しかし、管理する仕組みが出来てもコンポーネントの適切な作り方が分かってないとあまり意味がありません。
コンポーネントを作る際には、以下に注意する必要があります。
役割の明確化
コンポーネントごとに役割を明確にし、単一責任の法則に則りましょう。
コンポーネントはオブジェクトを形作るパーツなので、単品で機能して役割がはっきりしていたほうがオブジェクトを組みやすいのは想像できると思います。
また、何かしらの問題が出たときもその問題の処理を担当しているコンポーネントを確認すればいいため、メンテナンスもしやすくなります。
汎用的
特定のオブジェクトや用途に固有な処理ではなく、なるべく汎用的な機能を目指しましょう。
そのコンポーネントの役割を果たすのに必要のない依存関係をなくすことで、同じような役割が必要になった時に特に手を加える必要なくそのコンポーネントを使うことができます。
実行順
同じ仮想関数を継承した更新処理同士は、どんな実行順でも問題なく動作するように作りましょう。
行列の作成や描画などの実行順が特に重要な処理は、専用の管理クラスにコンポーネントの更新時に必要なデータを渡しておくことで、好きなタイミングで集めたデータを使って処理を実行することができます。
汎用的でない処理を記述するクラス
汎用的に作ったコンポーネントだけでゲームが作れるのが理想ですが、複雑なオブジェクトを作ろうとすると専用の処理が必要になってきます。
そこで、特定のオブジェクトの処理を記述するクラスの実装法を二つ紹介します。
GameObjectを継承して実装する
1つ目の方法は、GameObjectを継承して専用の処理を実装するという方法です。
初期化時にそのオブジェクトに必要なコンポーネントをアタッチし、更新関数でコンポーネント同士を連携させてオブジェクト固有の処理を行います。
こうすることでオブジェクトのコンポーネントの構成やコンポーネントの相互作用がクラス内で完結するため、外部から見てもわかりやすい形で管理できます。
コードによる実装のみでゲームを作る際はこちらの方法がおすすめですが、オブジェクト固有の処理が必要になってくるので、拡張性が重要なゲームやエディタを前提としたオブジェクト管理では適していないことが多いです。
専用のコンポーネントを作成する
2つ目の方法は、オブジェクトの処理を書いたコンポーネントを用意するという方法です。
オブジェクト専用の処理も一つのコンポーネントとして扱われるため、汎用的な処理で作っておくことでほかのオブジェクトから使いまわすことができます。
ただ、オブジェクトの初期化時にコンポーネントをアタッチする役割をもつクラスがなくなるため、オブジェクトごとの生成関数や外部ファイルなどの実装が追加で必要になります。
汎用的な処理を書くのが得意な人や、コンポーネントの操作、保存ができるエディタを作る予定の人におすすめの方法です。
おわりに
これで、コンポーネント指向設計でゲームを作ることができるようになりました。
皆さんもコンポーネントを付けたり外したりしてみてください。
何か間違いや改善案などあれば教えていただけますと幸いです。
Discussion