前回記事ではECS/Entity Component SystemのメリットやC++での実現方法について紹介しました。
実際の開発現場でECSを使用する場合、イベントシステム、効率的なイテレーション、System の設計など、より実務的な観点を考慮する必要があります。
本記事ではEnttというライブラリを使用して、実務で通用するECSの構築方法を解説します。
前提知識
-
C++の基礎文法を知っている -
ECSを知っている(知らない方は前回記事をご参照下さい)
Enttとは
Entt/enttは、C++向けの高速・軽量・柔軟の三拍子がそろったECSライブラリです。
自前のでECSを実装するよりも安全で高速なため、ゲームエンジンやリアルタイムアプリケーションで広く使用されています。
EnTTライブラリのリポジトリ👇
導入方法
EnTTライブラリはヘッダーオンリーですので非常に簡単に導入が可能です。
特に特殊なこともないので、以下の記事をご参照ください。
余談ですが公開しているサンプルリポジトリはサブモジュールで管理を行っています。
ヘッダーオンリーライブラリの導入方法👇
サブモジュールでの管理方法👇
公開リポジトリでのプロジェクトから見たインクルードパスは$(ProjectDir)..\External\entt\single_includeとなっております。
サンプルコード
本記事のサンプルコードはGithubにてすべて公開しております。
最後の方のサンプルコードは長くなりすぎるため、重要な点のみを抜き出して紹介しますので、適宜リポジトリの方をご参照ください。
最も単純なECSサンプル
//--------------------------------------------------------
//! @file main.cpp
//! @brief EnTTの基本的な使い方を示すサンプルコード
//! @author つきの
//--------------------------------------------------------
#include <iostream>
#include <entt/entt.hpp>
//--------------------------------------------------------
//! @struct Position
//! @brief 位置を表すコンポーネント
//--------------------------------------------------------
struct Position {
float x;
float y;
};
//--------------------------------------------------------
//! @struct Velocity
//! @brief 速度を表すコンポーネント
//--------------------------------------------------------
struct Velocity {
float x;
float y;
};
// エントリポイント
int main() {
// Registry を作成
entt::registry registry;
// Entity を作成
entt::entity entity = registry.create();
// Component を付与
registry.emplace<Position>(entity, 0.0f, 0.0f);
registry.emplace<Velocity>(entity, 1.0f, 0.5f);
// Position と Velocity を持つ Entity を走査
const auto view = registry.view<Position, Velocity>();
// Entity を更新
view.each([](auto e, auto& pos, auto& vel) {
// 位置を更新
pos.x += vel.x;
pos.y += vel.y;
// 更新後の位置を表示
std::cout << "Entity " << static_cast<int>(e)
<< " Position : (" << pos.x << ", " << pos.y << ")\n";
});
// プログラムの終了
return 0;
}
Entityの作成方法
entt::registry内のcreate()関数を呼ぶことで、Entityを生成することが出来ます。
EnTTでも、EntityはあくまでただのIDであり、データやふるまいを持ちません。
entt::entity entity = registry.create();
Componentの付与方法
entt::registry内のemplace<T>()関数を呼ぶことで、EntityにComponentを付与することが出来ます。
| 引数 | 型 | 説明 |
|---|---|---|
entity |
entt::entity |
コンポーネントを追加する対象のエンティティ |
0.0f |
float |
Position.x の初期値 |
0.0f |
float |
Position.y の初期値 |
第二引数以降はTに渡される引数となっています。
registry.emplace<Position>(entity, 0.0f, 0.0f);
特定のComponentを持つEntityを取得
entt::registry内のview<>()関数を呼ぶことで、テンプレート内のすべてのComponentを持つすべてのEntityが取得できます。
const auto view = registry.view<Position, Velocity>();
viewからComponentに処理を加える
view内のeach()関数を使用することで、Componentに対して処理を行うことが出来ます。
each()には処理を行うラムダ式を渡します。
ラムダ式の引数は以下の通りです。
| 引数 | 型 | 説明 |
|---|---|---|
entity |
entt::entity |
対象エンティティの ID。省略可能(受け取らない書き方もできる)。 |
component1 |
T1& または const T1&
|
view<...> に指定した最初のコンポーネント。参照で渡される。 |
component2 |
T2& または const T2&
|
2 つ目のコンポーネント。参照で渡される。 |
componentN |
TN& または const TN&
|
N 番目のコンポーネント。参照で渡される。 |
第二引数からは、Componentの分だけ順番に渡していきます。
Systemを関数で作るサンプル
//--------------------------------------------------------
//! @file main.cpp
//! @brief EnTTでSystemを設計する最小サンプル
//! @author つきの
//--------------------------------------------------------
#include <iostream>
#include <entt/entt.hpp>
//--------------------------------------------------------
//! @struct Position
//! @brief 位置を表すコンポーネント
//--------------------------------------------------------
struct Position {
float x;
float y;
};
//--------------------------------------------------------
//! @struct Velocity
//! @brief 速度を表すコンポーネント
//--------------------------------------------------------
struct Velocity {
float x;
float y;
};
//--------------------------------------------------------
//! @brief 位置更新システム
//! @param registry [in,out] EnTTのレジストリ
//! @details Position と Velocity を持つ Entity の位置を更新する
//--------------------------------------------------------
void UpdatePositionSystem(entt::registry& registry) {
// Position と Velocity を持つ Entity を走査
const auto view = registry.view<Position, Velocity>();
// Entity を更新
view.each([](auto entity, Position& pos, const Velocity& vel) {
pos.x += vel.x;
pos.y += vel.y;
// 更新後の位置を表示
std::cout << "Entity " << static_cast<int>(entity)
<< " Position : (" << pos.x << ", " << pos.y << ")\n";
});
}
// エントリポイント
int main() {
// Registry を作成
entt::registry registry;
// Entity を作成
entt::entity entity = registry.create();
// Component を付与
registry.emplace<Position>(entity, 0.0f, 0.0f);
registry.emplace<Velocity>(entity, 1.0f, 0.5f);
// 位置更新システムを実行
UpdatePositionSystem(registry);
// プログラムの終了
return 0;
}
Systemの組み込み
entt::registryを引数に渡すことで、内部でviewを呼んで処理を施すことで、責務分離がさらに美しくなります。
//--------------------------------------------------------
//! @brief 位置更新システム
//! @param registry [in,out] EnTTのレジストリ
//! @details Position と Velocity を持つ Entity の位置を更新する
//--------------------------------------------------------
void UpdatePositionSystem(entt::registry& registry) {
// Position と Velocity を持つ Entity を走査
const auto view = registry.view<Position, Velocity>();
// Entity を更新
view.each([](auto entity, Position& pos, const Velocity& vel) {
pos.x += vel.x;
pos.y += vel.y;
// 更新後の位置を表示
std::cout << "Entity " << static_cast<int>(entity)
<< " Position : (" << pos.x << ", " << pos.y << ")\n";
});
}
GameLoopサンプル
//--------------------------------------------------------
//! @file main.cpp
//! @brief EnTTでGameLoopを構築する最小サンプル
//! @author つきの
//--------------------------------------------------------
#include <iostream>
#include <entt/entt.hpp>
#include <chrono>
#include <thread>
//--------------------------------------------------------
//! @struct Position
//! @brief 位置を表すコンポーネント
//--------------------------------------------------------
struct Position {
float x;
float y;
};
//--------------------------------------------------------
//! @struct Velocity
//! @brief 速度を表すコンポーネント
//--------------------------------------------------------
struct Velocity {
float x;
float y;
};
//--------------------------------------------------------
//! @brief 位置更新システム
//! @param registry [in,out] EnTTのレジストリ
//! @details Position と Velocity を持つ Entity の位置を更新する
//--------------------------------------------------------
void UpdatePositionSystem(entt::registry& registry) {
// Position と Velocity を持つ Entity を走査
const auto view = registry.view<Position, Velocity>();
// Entity を更新
view.each([](auto entity, Position& pos, const Velocity& vel) {
pos.x += vel.x;
pos.y += vel.y;
// 更新後の位置を表示
std::cout << "Entity " << static_cast<int>(entity)
<< " Position : (" << pos.x << ", " << pos.y << ")\n";
});
}
//--------------------------------------------------------
//! @brief GameLoop
//! @param registry [in,out] EnTTのレジストリ
//! @details 毎フレームSystemを実行する
//--------------------------------------------------------
void GameLoop(entt::registry& registry) {
// この関数内でのみ時間リテラルを有効にする
using namespace std::chrono_literals;
// 簡易的なゲームループ(5フレーム分)
for (int frame = 0; frame < 5; ++frame) {
std::cout << "---- Frame " << frame << " ----\n";
// System を実行
UpdatePositionSystem(registry);
// 簡易的なフレーム待機(60fps 相当)
std::this_thread::sleep_for(16ms);
}
}
// エントリポイント
int main() {
// Registry を作成
entt::registry registry;
// Entity を作成
entt::entity entity = registry.create();
registry.emplace<Position>(entity, 0.0f, 0.0f);
registry.emplace<Velocity>(entity, 1.0f, 0.5f);
// GameLoopを疑似的に行う
GameLoop(registry);
// プログラムの終了
return 0;
}
フレーム待機
std::this_thread::空間のsleep_for()関数を呼ぶことで、スレッド(GameLoop)を停止させることができます。
一秒が1000msで、60fpsの場合の1フレームの長さは
1000 ÷ 60 = 16.666....
ということで、本記事では16ms間停止させます。
// 簡易的なフレーム待機(60fps 相当)
std::this_thread::sleep_for(16ms);
システムのクラス化サンプル
//--------------------------------------------------------
//! @file main.cpp
//! @brief System をクラス化した最小 ECS サンプル(複数 System 版)
//! @author つきの
//--------------------------------------------------------
#include <iostream>
#include <vector>
#include <entt/entt.hpp>
#include <chrono>
#include <thread>
//--------------------------------------------------------
//! @struct Position
//! @brief 位置を表すコンポーネント
//--------------------------------------------------------
struct Position {
float x;
float y;
};
//--------------------------------------------------------
//! @struct Velocity
//! @brief 速度を表すコンポーネント
//--------------------------------------------------------
struct Velocity {
float x;
float y;
};
//--------------------------------------------------------
//! @class ISystem
//! @brief 全ての System の基底クラス
//--------------------------------------------------------
class ISystem {
public:
//--------------------------------------------------------
//! @brief デストラクタ
//--------------------------------------------------------
virtual ~ISystem() = default;
//--------------------------------------------------------
//! @brief System の更新処理
//! @param registry [in,out] EnTT のレジストリ
//--------------------------------------------------------
virtual void Update(entt::registry& registry) = 0;
};
//--------------------------------------------------------
//! @class MovementSystem
//! @brief Position と Velocity を使って位置を更新する System
//--------------------------------------------------------
class MovementSystem : public ISystem {
public:
//--------------------------------------------------------
//! @brief 移動処理の更新
//! @param registry [in,out] EnTT のレジストリ
//--------------------------------------------------------
void Update(entt::registry& registry) override {
// PositionとVelocityを持つEntity を取得
const auto view = registry.view<Position, Velocity>();
// 各Entityの位置を速度分だけ更新
view.each([](auto entity, Position& pos, const Velocity& vel) {
pos.x += vel.x;
pos.y += vel.y;
});
}
};
//--------------------------------------------------------
//! @class LogSystem
//! @brief Position を使って位置をログ出力する System
//--------------------------------------------------------
class LogSystem : public ISystem {
public:
//--------------------------------------------------------
//! @brief ログ出力処理の更新
//! @param registry [in,out] EnTT のレジストリ
//--------------------------------------------------------
void Update(entt::registry& registry) override {
// Positionを持つEntity を取得
const auto view = registry.view<Position>();
// 各Entityの位置をログ出力
view.each([](auto entity, const Position& pos) {
std::cout << "Entity " << static_cast<int>(entity)
<< " Render Position : (" << pos.x << ", " << pos.y << ")\n";
});
}
};
//--------------------------------------------------------
//! @class GameLoop
//! @brief System をまとめて実行するループ
//--------------------------------------------------------
class GameLoop {
public:
//--------------------------------------------------------
//! @brief System を追加
//! @param system [in] 追加する System のポインタ
//--------------------------------------------------------
void AddSystem(ISystem* system) {
systems_.push_back(system);
}
//--------------------------------------------------------
//! @brief GameLoop の実行
//! @param registry [in,out] EnTT のレジストリ
//--------------------------------------------------------
void Run(entt::registry& registry) {
// 時間リテラルを使用するためのusing宣言
using namespace std::chrono_literals;
// 簡単なループでSystemを更新
for (int frame = 0; frame < 5; ++frame) {
std::cout << "---- Frame " << frame << " ----\n";
// 各Systemを更新
for (auto* system : systems_) {
system->Update(registry);
}
// フレームレートを制限するために少し待つ
std::this_thread::sleep_for(16ms);
}
}
private:
std::vector<ISystem*> systems_; // System のリスト
};
// エントリポイント
int main() {
// EnTT のレジストリを作成
entt::registry registry;
// Entity 作成
auto entity = registry.create();
registry.emplace<Position>(entity, 0.0f, 0.0f);
registry.emplace<Velocity>(entity, 1.0f, 0.5f);
// GameLoop と System の準備
GameLoop loop;
MovementSystem movement;
LogSystem render;
// SystemをGameLoopに追加
loop.AddSystem(&movement);
loop.AddSystem(&render);
// 実行
loop.Run(registry);
//プログラムの終了
return 0;
}
システムの抽象クラス
Update()関数を純粋仮想関数として持つことで、継承したクラスのUpdate()はアップキャストでまとめて処理を行うことができます。
//--------------------------------------------------------
//! @class ISystem
//! @brief 全ての System の基底クラス
//--------------------------------------------------------
class ISystem {
public:
//--------------------------------------------------------
//! @brief デストラクタ
//--------------------------------------------------------
virtual ~ISystem() = default;
//--------------------------------------------------------
//! @brief System の更新処理
//! @param registry [in,out] EnTT のレジストリ
//--------------------------------------------------------
virtual void Update(entt::registry& registry) = 0;
};
//--------------------------------------------------------
//! @class GameLoop
//! @brief System をまとめて実行するループ
//--------------------------------------------------------
class GameLoop {
public:
//--------------------------------------------------------
//! @brief System を追加
//! @param system [in] 追加する System のポインタ
//--------------------------------------------------------
void AddSystem(ISystem* system) {
systems_.push_back(system);
}
//--------------------------------------------------------
//! @brief GameLoop の実行
//! @param registry [in,out] EnTT のレジストリ
//--------------------------------------------------------
void Run(entt::registry& registry) {
// 時間リテラルを使用するためのusing宣言
using namespace std::chrono_literals;
// 簡単なループでSystemを更新
for (int frame = 0; frame < 5; ++frame) {
std::cout << "---- Frame " << frame << " ----\n";
// 各Systemを更新
for (auto* system : systems_) {
system->Update(registry);
}
// フレームレートを制限するために少し待つ
std::this_thread::sleep_for(16ms);
}
}
private:
std::vector<ISystem*> systems_; // System のリスト
};
ファイルの分割サンプル
04のサンプルをファイル分割したものです。
実際の開発ではpch.hなどを入れたり、エンジン層を作ったりといったことを行います。
私の方針としましては、#includeを行うヘッダーファイルはincludeディレクトリへ入れ、プロパティの追加のインクルードディレクトリに入れる手法を推奨します。
システムマネージャーの実装サンプル
priorityとsystemの構造体を作ることで、マネージャー追加時にソートを行い、システムの優先順位を指定できます。
UpdateAll()をSystemManagerが持つことで、責務分離も明確になります。
//--------------------------------------------------------
//! @file SystemManager.hpp
//! @brief Systemの登録・実行を管理するクラス定義
//! @author つきの
//--------------------------------------------------------
#pragma once
#include <vector>
#include <algorithm>
#include <entt/entt.hpp>
class ISystem; // 前方宣言
//--------------------------------------------------------
//! @class SystemManager
//! @brief System の登録と実行順序(優先度)を管理するクラス
//--------------------------------------------------------
class SystemManager {
public:
//--------------------------------------------------------
// System を登録する
//! @param system [in] 登録する System
//! @param priority [in] 実行優先度(小さいほど先に実行)
//--------------------------------------------------------
void AddSystem(ISystem* system, int priority);
//--------------------------------------------------------
// 登録された System を優先度順に実行する
//! @param registry [in,out] EnTT のレジストリ
//--------------------------------------------------------
void UpdateAll(entt::registry& registry);
private:
//--------------------------------------------------------
//! @struct Systemと優先度をまとめた構造体
//--------------------------------------------------------
struct SystemEntry {
int priority; // 実行優先度
ISystem* system; // System のポインタ
};
std::vector<SystemEntry> systems_; // 登録された System とその優先度のリスト
};
//--------------------------------------------------------
//! @file SystemManager.cpp
//! @brief SystemManagerクラスの実装
//! @author つきの
//--------------------------------------------------------
#include <Core/SystemManager.hpp>
#include <Systems/ISystem.hpp>
//--------------------------------------------------------
//! @brief Systemを追加し、優先度順にソートする
//--------------------------------------------------------
void SystemManager::AddSystem(ISystem* system, int priority) {
// System と優先度をリストに追加
systems_.push_back({ priority, system });
// 優先度の昇順でソート
std::sort(systems_.begin(), systems_.end(),
[](const SystemEntry& a, const SystemEntry& b) {
return a.priority < b.priority;
});
}
//--------------------------------------------------------
//! @brief 登録されたSystemを優先度順に実行
//--------------------------------------------------------
void SystemManager::UpdateAll(entt::registry& registry) {
// 優先度順に System を実行
for (auto& entry : systems_) {
entry.system->Update(registry);
}
}
DeltaTimeの実装サンプル
過去にDeltaTimeの仕組みと取得法について解説しているので、実装方法の詳細はそちらをご覧ください。
//--------------------------------------------------------
//! @file GameLoop.cpp
//! @brief GameLoop クラスの実装
//! @author つきの
//--------------------------------------------------------
#include <Core/GameLoop.hpp>
#include <chrono>
#include <thread>
#include <iostream>
//--------------------------------------------------------
//! @brief ゲームループの実行を行う関数
//--------------------------------------------------------
void GameLoop::Run(entt::registry& registry, SystemManager& systemManager) {
// 時間リテラルを使用するための名前空間
using namespace std::chrono_literals;
// 長いので型エイリアスを使用して簡略化
using clock = std::chrono::steady_clock;
// ゲームループ開始前の時間を記録
auto last = clock::now();
// 簡単なゲームループ(5フレーム分だけ)
for (int frame = 0; frame < 5; ++frame) {
//--------------------------------------------------------
// ここで deltaTime を計算する
//--------------------------------------------------------
auto now = clock::now(); // 現在の時間を取得
float deltaTime = std::chrono::duration<float>(now - last).count();
last = now; // 現在の時間を last に保存
// デバッグ用に deltaTime を表示
std::cout << "---- Frame " << frame << " (dt=" << deltaTime << ") ----\n";
// SystemManager に実行を委譲
systemManager.UpdateAll(registry, deltaTime);
// フレームレートを制限するために少し待つ(例: 60 FPS なら約16ms)
std::this_thread::sleep_for(16ms);
}
}
移動処理にDeltaTimeを乗算することで、フレームレートに左右されない移動処理を行うことができます。
//--------------------------------------------------------
//! @file MovementSystem.cpp
//! @brief 座標を更新するSystemのクラス定義
//! @author つきの
//--------------------------------------------------------
#include <Systems/MovementSystem.hpp>
#include <Components/Position.hpp>
#include <Components/Velocity.hpp>
//--------------------------------------------------------
//! @brief 移動処理の更新
//--------------------------------------------------------
void MovementSystem::Update(entt::registry& registry, float deltaTime) {
// PositionとVelocityを持つEntity を取得
const auto view = registry.view<Position, Velocity>();
// 各Entityの位置を速度分だけ更新
view.each([deltaTime](auto entity, Position& pos, const Velocity& vel) {
pos.x += vel.x * deltaTime;
pos.y += vel.y * deltaTime;
});
}
Transformと階層構造実装サンプル
//--------------------------------------------------------
//! @file Transform.hpp
//! @brief 2DTransform(位置・回転・スケール + 親子関係)
//! @author つきの
//--------------------------------------------------------
#pragma once
#include <vector>
//--------------------------------------------------------
//! @struct Transform
//! @brief 2DTransform(位置・回転・スケール + 親子関係)
//--------------------------------------------------------
struct Transform {
// ローカル座標系
float x = 0.0f; // ローカルx座標
float y = 0.0f; // ローカルy座標
float rotation = 0.0f; // ラジアン
float scaleX = 1.0f; // ローカルxスケール
float scaleY = 1.0f; // ローカルyスケール
// 親子関係
Transform* parent = nullptr; // 親Transformへのポインタ
std::vector<Transform*> children; // 子Transformへのポインタリスト
// ワールド変換キャッシュ
bool dirty = true; // ワールド変換が最新かどうかのフラグ
float worldX = 0.0f; // ワールドx座標
float worldY = 0.0f; // ワールドy座標
float worldRotation = 0.0f; // ワールド回転
float worldScaleX = 1.0f; // ワールドxスケール
float worldScaleY = 1.0f; // ワールドyスケール
//--------------------------------------------------------
//! @brief 親を設定する(子リストへの登録も行う)
//! @param newParent 新しい親Transformへのポインタ
//--------------------------------------------------------
void SetParent(Transform* newParent) {
// 既存の親から子リストを削除
parent = newParent;
// 新しい親の子リストに自身を追加
if (parent) {
parent->children.push_back(this);
}
// ワールド変換を更新する必要がある
dirty = true;
}
//--------------------------------------------------------
//! @brief 自身と子の worldTransform を更新する(再帰)
//--------------------------------------------------------
void UpdateWorldTransform() {
if (parent) {
// 親の world を継承
worldX = parent->worldX + x;
worldY = parent->worldY + y;
worldRotation = parent->worldRotation + rotation;
worldScaleX = parent->worldScaleX * scaleX;
worldScaleY = parent->worldScaleY * scaleY;
}
else {
// 親がいない場合は local = world
worldX = x;
worldY = y;
worldRotation = rotation;
worldScaleX = scaleX;
worldScaleY = scaleY;
}
dirty = false;
// 子も再帰的に更新
for (auto* child : children) {
// 子のワールド変換も更新する必要がある
child->dirty = true;
// 子のワールド変換を更新
child->UpdateWorldTransform();
}
}
};
親子関係
SetParent()で親のポインタを渡すことで、Transform内部に親子関係を持つことができます。
OOPではCompositeパターンなどでGamaObjectが階層構造を持つことが多いですが、ECSではTransformが階層を持つという方式が一般的です。
他のComponentで階層を確認したい場合は、viewでTransformをテンプレートに入れれば参照することが可能です。
//--------------------------------------------------------
//! @brief 親を設定する(子リストへの登録も行う)
//! @param newParent 新しい親Transformへのポインタ
//--------------------------------------------------------
void SetParent(Transform* newParent) {
// 既存の親から子リストを削除
parent = newParent;
// 新しい親の子リストに自身を追加
if (parent) {
parent->children.push_back(this);
}
// ワールド変換を更新する必要がある
dirty = true;
}
親への再帰的な追従
ゲームオブジェクトが階層構造(親子関係)を持つ場合、 子オブジェクトのワールド座標は親のワールド座標を基準に計算する必要があります。
親の位置を基準に、子のローカル位置を足し合わせることで、子のワールド座標を計算することができます。
dirtyフラグがtrueなら更新が必要、falseであれば更新済みとすることで、無駄な計算を避けることができます。
//--------------------------------------------------------
//! @brief 自身と子の worldTransform を更新する(再帰)
//--------------------------------------------------------
void UpdateWorldTransform() {
if (parent) {
// 親の world を継承
worldX = parent->worldX + x;
worldY = parent->worldY + y;
worldRotation = parent->worldRotation + rotation;
worldScaleX = parent->worldScaleX * scaleX;
worldScaleY = parent->worldScaleY * scaleY;
}
else {
// 親がいない場合は local = world
worldX = x;
worldY = y;
worldRotation = rotation;
worldScaleX = scaleX;
worldScaleY = scaleY;
}
dirty = false;
// 子も再帰的に更新
for (auto* child : children) {
// 子のワールド変換も更新する必要がある
child->dirty = true;
// 子のワールド変換を更新
child->UpdateWorldTransform();
}
}
UpdateWorldTransform()関数内で子オブジェクトのUpdateWorldTransform()を呼ぶことで、再帰的にすべての子Transformの位置が漏れなく更新されます。
// 子も再帰的に更新
for (auto* child : children) {
// 子のワールド変換も更新する必要がある
child->dirty = true;
// 子のワールド変換を更新
child->UpdateWorldTransform();
}
イベント駆動ECSの実装サンプル
本実装ではsubscribe()で受け取るコールバック関数を指定、publish()関数で処理をキューに積み、dispatch()でキューに積まれたイベントを発火させています。
即時発火ではなくdispatch()方式にすることで、フレームの最後に発火させるまでComponentの状態が安定します。
また、Systemの疎結合を維持することもできます。
//--------------------------------------------------------
//! @file EventBus.hpp
//! @brief シンプルなイベントバス(publish / subscribe / dispatch)
//! @author つきの
//--------------------------------------------------------
#pragma once
#include <unordered_map>
#include <vector>
#include <functional>
#include <typeindex>
#include <any>
//--------------------------------------------------------
//! @class EventBus
//! @brief シンプルなイベントバス(publish / subscribe / dispatch)
//--------------------------------------------------------
class EventBus {
public:
//--------------------------------------------------------
//! @brief Event を発行(キューに積む)
//! @tparam Event 発行するイベントの型
//! @param event [in] 発行するイベントのインスタンス
//--------------------------------------------------------
template<typename Event>
void publish(const Event event) {
// イベントを std::any に変換してキューに積む
events_[typeid(Event)].push_back(event);
}
//--------------------------------------------------------
//! @brief Event を購読(コールバック登録)
//! @tparam Event 購読するイベントの型
//! @param callback [in] イベントを受け取るコールバック関数(引数は const Event&)
//--------------------------------------------------------
template<typename Event>
void subscribe(std::function<void(const Event&)> callback) {
// コールバックを std::function<void(const std::any&)> に変換して登録
auto& vec = subscribers_[typeid(Event)];
// 登録されたコールバックは std::anyをEventにキャストして呼び出す
vec.push_back([callback](const std::any& e) {
callback(std::any_cast<const Event&>(e));
});
}
//--------------------------------------------------------
//! @brief 1フレーム分のEventを配信
//--------------------------------------------------------
void dispatch() {
//--------------------------------------------------------
// イベントキューを走査して、登録されたコールバックを呼び出す
//--------------------------------------------------------
for (auto& [type, eventList] : events_) {
// イベントの型に対応する購読者リストを取得
auto subIt = subscribers_.find(type);
// 購読者がいない場合はスキップ
if (subIt == subscribers_.end()) continue;
// 各イベントに対して、登録されたコールバックを呼び出す
for (auto& e : eventList) {
// 登録されたコールバックを呼び出す
for (auto& callback : subIt->second) {
callback(e);
}
}
}
// 配信後はイベントキューをクリア
events_.clear();
}
private:
// Event キュー
std::unordered_map<std::type_index, std::vector<std::any>> events_;
// 購読者リスト
std::unordered_map<std::type_index,
std::vector<std::function<void(const std::any&)>>> subscribers_;
};
EventSystem
//--------------------------------------------------------
//! @file EventSystem.hpp
//! @brief EventBus.dispatch() を呼ぶだけの System
//! @author つきの
//--------------------------------------------------------
#pragma once
#include <Events/EventBus.hpp>
//--------------------------------------------------------
//! @class EventSystem
//! @brief EventBus.dispatch() を呼ぶだけの System
//--------------------------------------------------------
class EventSystem {
public:
//--------------------------------------------------------
//! @brief コンストラクタ
//! @param bus [in] EventBusのインスタンスへの参照
//--------------------------------------------------------
EventSystem(EventBus& bus) : bus_(bus) {}
//--------------------------------------------------------
//! @brief EventBus.dispatch() を呼ぶだけの更新処理
//! @param registry [in,out] EnTT のレジストリ(使用しない)
//--------------------------------------------------------
void Update() {
bus_.dispatch();
}
private:
EventBus& bus_;
};
// 簡単なゲームループ(5フレーム分だけ)
for (int frame = 0; frame < 5; ++frame) {
//--------------------------------------------------------
// ここで deltaTime を計算する
//--------------------------------------------------------
auto now = clock::now(); // 現在の時間を取得
float deltaTime = std::chrono::duration<float>(now - last).count();
last = now; // 現在の時間を last に保存
// デバッグ用に deltaTime を表示
std::cout << "---- Frame " << frame << " (dt=" << deltaTime << ") ----\n";
// SystemManager に実行を委譲
systemManager.UpdateAll(registry, deltaTime);
eventSystem.Update(); // dispatch
// フレームレートを制限するために少し待つ(例: 60 FPS なら約16ms)
std::this_thread::sleep_for(16ms);
}
SystemManagerを更新した後にEventSystemのUpdate内で分離して発火させることで、イベントの一斉発火が最後に行われることを保証します。
総括
-
EnTTライブラリを使用することで、効率的で高速なキャッシュへのアクセスが、わかりやすいAPIで使用することができる -
SystemManagerやEventBusで責務分離を行っていくことで実務レベルのECS設計を行うことができる


