Event Dispatcherの必要性
Unreal Engine (UE) でゲーム開発を進めていると、最初はシンプルだったはずのBlueprintやC++のクラス間の関係が、いつの間にか複雑に絡み合い、まるでスパゲッティコード のようになってしまう経験はありませんか?
例えば、「プレイヤーがアイテムを拾ったら、インベントリを更新し、UIに通知し、SEを再生する」という一連の処理を考えてみましょう。
素朴な実装では、アイテムクラスが直接「インベントリクラス」「UIクラス」「サウンドマネージャクラス」を参照し、それぞれの関数を呼び出すことになります。
この設計の問題点は、依存関係が密結合 していることです。
- インベントリの更新方法が変わったら、アイテムクラスも修正が必要になる。
- サウンドマネージャを別のものに置き換える際、アイテムクラスの参照先をすべて変更しなければならない。
- アイテムクラスは、本来知る必要のない「誰が」「何を」するのかという詳細を知ってしまっています。
このような密結合なシステムは、変更に弱く、拡張が困難で、バグの温床になりがちです。
本記事では、この問題を根本的に解決し、変更に強く、再利用性の高い疎結合(ルーズカップリング) なシステムを構築するための強力なツール、Event Dispatcher について、初心者から中級者向けに徹底的に解説します。
Event Dispatcherとは
Event Dispatcher(イベントディスパッチャー)は、Unreal Engineにおけるデリゲート(Delegate) の実装の一つであり、特定のイベントが発生したことを、そのイベントに関心を持つすべてのオブジェクトに通知するための仕組みです。これは、プログラミングにおけるObserverパターン (またはPublish-Subscribeパターン)を簡単に実現する方法と言えます。
Event Dispatcherの最大の役割は、「イベントの発生源」と「イベントの処理者」を完全に分離する ことです。
| 要素 | 役割 | 特徴 |
|---|---|---|
| イベントの発生源 (Caller) | Event Dispatcherを「呼び出す (Call/Broadcast)」 | 誰が処理するかを知らない |
| イベントの処理者 (Listener) | Event Dispatcherに「バインド (Bind)」する | イベントの発生源を知らない |
| Event Dispatcher | 発生源と処理者を仲介する | 間に立つことで依存関係を断ち切る |
これにより、発生源は「イベントが発生した」という事実だけを通知すればよく、処理者は「そのイベントが発生したら何かをする」という登録(バインド)をするだけで済みます。
BlueprintでのEvent Dispatcherの実装
Event Dispatcherは、主にBlueprintクラス内で定義・使用されます。
ステップ1: Event Dispatcherの定義
Event Dispatcherを定義したいBlueprint(例: BP_Item)を開き、「My Blueprint」パネルの「Event Dispatchers」セクションで新しいディスパッチャーを作成します。
- 名前:
OnItemPickedUpなど、イベントの内容がわかる名前にします。 - 引数: イベント発生時にリスナーに渡したいデータ(例: 拾ったプレイヤーの参照、アイテムのIDなど)を設定します。
ステップ2: イベントのバインド(登録)
イベントを処理したいBlueprint(例: BP_InventoryManager)で、BP_Item のインスタンスに対してイベントをバインドします。
BP_Itemの参照を取得します。- その参照から、定義したEvent Dispatcher(例:
OnItemPickedUp)を右クリックし、「Bind Event to OnItemPickedUp 」ノードを作成します。 - このノードの実行ピンに、イベント発生時に実行したい処理(Custom Event)を接続します。
Blueprint例(リスナー側: BP_InventoryManager):
// BeginPlayイベントなど、初期化時に実行
Event BeginPlay
-> Get Item Reference (BP_Item)
-> Bind Event to OnItemPickedUp
-> New Event (Custom Event: HandleItemPickup)
// Custom Eventの定義
Custom Event HandleItemPickup (Item Ref: BP_Item, Player Ref: BP_Player)
-> Add Item to Inventory
-> Update UI
ステップ3: イベントの呼び出し(通知)
イベントの発生源となるBlueprint(例: BP_Item)で、イベントが発生したタイミングでEvent Dispatcherを呼び出します。
- Event Dispatcher(例:
OnItemPickedUp)を右クリックし、「Call OnItemPickedUp 」ノードを作成します。 - 定義した引数に適切な値を渡して実行します。
Blueprint例(発生源側: BP_Item):
// プレイヤーがアイテムに触れた時など
Event OnComponentBeginOverlap
-> Call OnItemPickedUp (Self, Other Actor as BP_Player)
これで、BP_Item は誰がイベントを処理するかを知ることなく、イベントを通知できるようになり、BP_InventoryManager はアイテムクラスを知ることなく、イベントを購読できるようになりました。
C++でのEvent Dispatcherの実装
C++では、Event Dispatcherはマルチキャストデリゲート として実装されます。Blueprintからアクセス可能にするためには、特定のマクロを使用します。
ステップ1: デリゲートの宣言
ヘッダーファイル(例: Item.h)で、DECLARE_DYNAMIC_MULTICAST_DELEGATE マクロを使用してデリゲートを宣言します。
// Item.h
// 引数なしのデリゲート
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnItemPickedUpSignature);
// 引数を持つデリゲート (例: 拾ったアイテムとプレイヤー)
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnItemPickedUpWithParamsSignature, AItem*, PickedUpItem, APlayerCharacter*, Instigator);
UCLASS()
class AItem : public AActor
{
GENERATED_BODY()
public:
// Blueprintからアクセス可能なEvent Dispatcherとして 公開
UPROPERTY(BlueprintAssignable, Category = "Events")
FOnItemPickedUpWithParamsSignature OnItemPickedUp;
// ...
};
ステップ2: イベントの呼び出し(通知)
ソースファイル(例: Item.cpp)で、イベントが発生したタイミングで Broadcast 関数を呼び出します。
// Item.cpp
void AItem::OnOverlapBegin(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, ...)
{
// ... 必要なチェック ...
// イベントをブロードキャスト(通知)
OnItemPickedUp.Broadcast(this, Cast<APlayerCharacter>(OtherActor));
// ...
}
ステップ3: イベントのバインド(登録)
Blueprint側で、C++で定義した OnItemPickedUp Event Dispatcherに対して、前述のBlueprintの手順と同じようにイベントをバインドできます。C++側でバインドする場合は、AddDynamic 関数を使用します。
// InventoryManager.cpp
void AInventoryManager::BeginPlay()
{
Super::BeginPlay();
// Itemの参照を取得 (ここでは省略)
AItem* MyItem = GetItemReference();
if (MyItem)
{
// C++関数をデリゲートにバインド
MyItem->OnItemPickedUp.AddDynamic(this, &AInventoryManager::HandleItemPickup);
}
}
void AInventoryManager::HandleItemPickup(AItem* PickedUpItem, APlayerCharacter* Instigator)
{
// アイテムをインベントリに追加する処理
}
// 重要:オブジェクト破棄時にアンバインドする
void AInventoryManager::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
Super::EndPlay(EndPlayReason);
AItem* MyItem = GetItemReference();
if (MyItem)
{
// バインドを解除(メモリリーク・クラッシュ防止)
MyItem->OnItemPickedUp.RemoveDynamic(this, &AInventoryManager::HandleItemPickup);
}
}
💡 AddDynamicとRemoveDynamicの対応
AddDynamicでバインドした場合、対応するRemoveDynamicでアンバインドすることが重要です。特にWidgetやUI要素は頻繁に生成・破棄されるため、EndPlayやDestruct時に確実にアンバインドしないと、既に破棄されたオブジェクトへの参照が残り、クラッシュの原因になります。
ベストプラクティスとよくある間違い
Event Dispatcherは強力ですが、使い方を誤るとかえってコードが複雑になることがあります。
ベストプラクティス
- イベント名と引数を明確にする: イベント名(例:
OnHealthChanged)は、何が起こったか を明確に示し、引数はそのイベントに関する必要最低限の情報 のみを含めるように設計します。 - 不要になったら必ずUnbindする: 特にウィジェットや一時的なオブジェクトがイベントを購読する場合、そのオブジェクトが破棄される前に
Unbind Eventノード(C++ではRemoveDynamic)を使用してバインドを解除しないと、メモリリーク やアクセス違反 (既に破棄されたオブジェクトへのアクセス)の原因になります。 - イベントの発生源は知らなくて良い: リスナー側(バインドする側)は、イベントの発生源のクラスを知る必要はありません。Event Dispatcherの引数を通じて必要な情報を受け取るべきです。
よくある間違い
- Event Dispatcherを使いすぎる: すべての通信をEvent Dispatcherで行うと、コードの追跡が困難になります。直接的な関数呼び出し (密結合)とEvent Dispatcher (疎結合)を適切に使い分けることが重要です。
- 密結合が適している場合: 処理が単一のオブジェクトに限定され、変更の可能性が低い場合(例: 自分のコンポーネント内の関数呼び出し)。
- 疎結合が適している場合: 複数の異なるオブジェクトが同じイベントに関心を持つ場合(例: UIの更新、SEの再生、ゲームステートの変更)。
- 引数に大量のデータを渡す: Event Dispatcherの引数は、イベントの発生を通知するために必要な最小限のデータに留めるべきです。大量のデータが必要な場合は、リスナー側で発生源のオブジェクトを参照し、必要なプロパティを取得する方が、デリゲートのシグネチャをシンプルに保てます。
Event Dispatcherの活用指針
Event Dispatcherは、Unreal Engineで疎結合 なシステムを構築するための鍵となる機能です。
| 項目 | 密結合(直接呼び出し) | 疎結合(Event Dispatcher) |
|---|---|---|
| 依存関係 | 発生源が処理者を直接知っている | 発生源と処理者が互いを知らない |
| 変更容易性 | 低い。変更が他のクラスに波及しやすい | 高い。リスナーを追加・削除しても発生源は影響を受けない |
| 拡張性 | 低い。新しい処理を追加するたびに発生源を修正する必要がある | 高い。新しいリスナーをバインドするだけで機能を追加できる |
Event Dispatcherを適切に活用することで、大規模なプロジェクトでもメンテナンスしやすく、拡張性の高い、堅牢なゲームシステムを構築することができます。まずは小さな機能からEvent Dispatcherを導入し、そのメリットを実感してみてください。