データベース管理とランタイムクエリ
静的ワークフローでは [SerializeField] GameEvent 参照で事件とスクリプトを結びつけます。シーン内ロジックには最適ですが、イベントが動的コンテンツの境界の向こう側にある場合 —— Addressables、DLC、Mod、セーブ/ロードシステム、シリアライズフィールドを持てない純粋な C# ドメインコード —— では立ち行かなくなります。
GameEventManager.Instance はそのギャップを埋める二つの相補的な API ファミリーを提供します:
- データベース・ライフサイクル —— 実行時に
Register/Unregister/SetActive - ランタイムクエリ —— GUID、名前、カテゴリ、型 からイベントを検索(直接アセット参照不要)
これらを組み合わせることで、任意のコード(非 MonoBehaviour であっても)が、配線済み参照ではなくデータとしてイベントを発見・利用できるようになります。
🎯 使いどころ
| シナリオ | 推奨アプローチ |
|---|---|
| シーンに紐付く MonoBehaviour 内のロジック | 静的ワークフロー —— [SerializeField] GameEvent |
| 純粋な C# クラス/サービス/ドメイン層 | ランタイムクエリ GUID または名前で |
| セーブ/ロード —— 永続化したデータからイベントを再発火 | ランタイムクエリ GUID で(ID が安定) |
| シーン/DLC/Mod が実行時にイベントを追加 | データベース・ライフサイクル —— RegisterDatabase |
| ポーズ/カットシーンで一括ミュートしたい | データベース・ライフサイクル —— SetDatabaseActive(false) |
| 名前パターンに一致するイベントすべてが必要なプラグイン | ランタイムクエリ —— GetGameEvents(name) |
シーンロード時に手書きで Dictionary<string, GameEvent> を埋めている自分に気づいたら、それは Query API を再発明している証拠です。素直にそちらを使ってください。
1. データベース・ライフサイクル
GameEventDatabase は GameEvent サブアセットのリストを持つ ScriptableObject です。GameEventManager が登録済みデータベースの順序付きリストを保持しており、登録済みかつアクティブなデータベース内のイベントだけが発火・バインディングに参加します。
Register & Unregister
using TinyGiants.GES.Runtime;
using UnityEngine;
public class CombatModuleLoader : MonoBehaviour
{
[SerializeField] private GameEventDatabase combatDB;
private void OnEnable()
{
GameEventManager.Instance.RegisterDatabase(combatDB);
}
private void OnDisable()
{
GameEventManager.Instance.UnregisterDatabase(combatDB);
}
}
RegisterDatabase はバインディングが未生成のイベントに対して自動的に EventBinding エントリを作成します。すでに登録済みのデータベースを再登録しても安全な no-op です。UnregisterDatabase はバインディングと実行時コールバックをクリーンアップし、null や未登録のデータベースに対しても安全に呼び出せます。
Active vs Inactive
SetDatabaseActive(db, false) は登録は維持したまま、データベース内のイベントを Raise 時にサイレント no-op にします。SetDatabaseActive(db, true) を呼べば呼び出しが再開されます。
// カットシーン中に戦闘イベントをすべてミュート
GameEventManager.Instance.SetDatabaseActive(combatDB, false);
PlayCutscene();
// カットシーン終了後に通常の呼び出しを再開
GameEventManager.Instance.SetDatabaseActive(combatDB, true);
| 操作 | リスト所属 | イベント呼び出し | 用途 |
|---|---|---|---|
RegisterDatabase(db) | ✅ 追加 | ✅ アクティブ | 新コンテンツのロード |
UnregisterDatabase(db) | ❌ 削除 | ❌ 非アクティブ | コンテンツのアンロード(完全クリーンアップ) |
SetDatabaseActive(db, false) | ✅ 維持 | ❌ ミュート | 状態を失わずに一時的にゲート |
SetDatabaseActive(db, true) | ✅ 維持 | ✅ アクティブ | 一時ゲート解除 |
パターン:Resources でロード
var db = Resources.Load<GameEventDatabase>("Databases/CombatEvents");
GameEventManager.Instance.RegisterDatabase(db);
パターン:Addressables でロード
var handle = Addressables.LoadAssetAsync<GameEventDatabase>("CombatEvents");
var db = await handle.Task;
GameEventManager.Instance.RegisterDatabase(db);
パターン:Mod システム
// Mod は単独の GameEventDatabase アセットとして配布される
foreach (var modDB in installedMods)
GameEventManager.Instance.RegisterDatabase(modDB);
// ユーザーが Mod を無効化(アンロードはせず)
GameEventManager.Instance.SetDatabaseActive(disabledMod, false);
2. ランタイムクエリ
クエリ API には三種類の検索方式があり、それぞれに三つの型アリティが対応します。検索方式は「どうやってイベントを見つけるか」、型アリティは「どの型を強制するか」を答えます。
検索方式 1:GUID で(一意)
GameEvent アセットの Guid は作成時に決定し決して変わりません —— 永続化(セーブ、ネットワーク、Mod メタデータ)に使える唯一の安定した ID です。
var mgr = GameEventManager.Instance;
// 存在チェック
if (mgr.HasGameEvent(savedGuid))
{
// 取得
var evt = mgr.GetGameEvent(savedGuid);
evt?.Raise();
// または try パターン
if (mgr.TryGetGameEvent(savedGuid, out var found))
found.Raise();
}
検索方式 2:名前で(複数マッチ)
イベント名は一意ではありません —— 異なるカテゴリやデータベースをまたいで同じ名前が存在し得ます。複数形 API はすべての一致を返します:
List<GameEventBase> jumps = mgr.GetGameEvents("OnJump");
foreach (var e in jumps) e.Raise();
// オプションのカテゴリフィルタ付き
List<GameEventBase> movementJumps = mgr.GetGameEvents("OnJump", "Movement");
GetGameEvents は常に List<T> を返し(一致なしなら空リスト)、null にはなりません。null チェックなしで直接 foreach 可能です。
検索方式 3:名前で先頭一致(便利関数)
一致が一つだけだと分かっている場合(または任意の一致でよい場合)、便利関数を使います:
mgr.GetFirstGameEventByName("OnPlayerSpawn")?.Raise();
// カテゴリフィルタ付き
mgr.GetFirstGameEventByName("OnJump", "Movement")?.Raise();
// try パターン
if (mgr.TryGetFirstGameEventByName("OnPlayerSpawn", out var evt))
evt.Raise();
「先頭」はイテレータが最初に出会ったもの —— 重複時は任意の一つです。特定の順序に依存しないこと。順序が重要なら GetGameEvents を使って明示的に選んでください。
厳密な型フィルタリング:3 つのアリティ
すべての Get/Has/Try メソッドに三つのアリティがあります:
| アリティ | 一致対象 | 戻り値 |
|---|---|---|
| 非ジェネリック | 任意のイベント型 | GameEventBase |
<T> | GameEvent<T> のみ | GameEvent<T> |
<TSender, TArgs> | GameEvent<TSender, TArgs> のみ | GameEvent<TSender, TArgs> |
フィルタは厳密 —— 代入互換ではなく is GameEvent<T> パターンマッチを使います。三つのイベントクラス(void、単一引数、sender)はすべて兄弟(直接 GameEventBase を継承)なので、型をまたぐ誤マッチは起こりません。
// データベースの内容:
// - "OnJump" (void, Movement)
// - "OnJump" (void, UI)
// - "OnScoreChanged" (int, Gameplay)
mgr.GetGameEvents("OnJump").Count; // → 2 (void イベント 2 件)
mgr.GetGameEvents<int>("OnJump").Count; // → 0 (OnJump は void であり int ではない)
mgr.GetGameEvents("OnJump", "Movement").Count; // → 1 (Movement カテゴリのみ)
mgr.GetFirstGameEventByName<int>("OnScoreChanged")?.Raise(100);
アクティブデータベースのスコープ
すべてのクエリメソッドは登録済みかつアクティブなデータベースのみを走査します。未登録のデータベース、または SetDatabaseActive(false) でミュートされたデータベース内のイベントはクエリから完全に不可視です —— Raise() から不可視なのと同じ挙動です。
これは意図的な設計です:クエリ結果は「結果に対して Raise() を呼んだ場合に実際に発火するもの」を反映します。
3. レシピ集
レシピ:セーブ&ロード
イベント GUID を永続化し、ロード時に復元:
[Serializable]
public class QuestState
{
public string completionEventGuid; // ディスクへシリアライズ
}
public class QuestSystem
{
public void OnQuestComplete(QuestState quest)
{
var mgr = GameEventManager.Instance;
if (mgr.TryGetGameEvent(quest.completionEventGuid, out var evt))
evt.Raise();
else
Debug.LogWarning($"Quest event GUID '{quest.completionEventGuid}' missing — DB unloaded?");
}
}
GUID はコードのリファクタ、アセットの移動、リネームにも耐えます —— イベントの削除や所属データベースの登録解除のみが GUID を無効化します。
レシピ:Addressables の遅延ロード
必要に応じてデータベースをロードし、イベントを発火し、完了後にアンロード:
public async Task FireBossDefeatedSequence()
{
var handle = Addressables.LoadAssetAsync<GameEventDatabase>("BossEvents");
var db = await handle.Task;
GameEventManager.Instance.RegisterDatabase(db);
// この時点でデータベース内のイベントはクエリ可能
if (GameEventManager.Instance.TryGetFirstGameEventByName("OnBossDefeated", out var evt))
evt.Raise();
// オプション:カットシーン後にアンロード
GameEventManager.Instance.UnregisterDatabase(db);
Addressables.Release(handle);
}
レシピ:純粋 C# サービス層
非 MonoBehaviour コードからもマネージャ経由でイベントを駆動できます:
public class AnalyticsService
{
private const string AnalyticsCategory = "Analytics";
public void TrackPlayerAction(string actionName)
{
var mgr = GameEventManager.Instance;
if (mgr == null) return;
// 規約でイベントを検索:名前 + カテゴリ
if (mgr.TryGetFirstGameEventByName<string>(actionName, out var evt, AnalyticsCategory))
evt.Raise(actionName);
}
}
[SerializeField] も MonoBehaviour もシーン配線も不要 —— サービス層はメタデータからイベントを見つけます。
レシピ:規約による一括発火
すべてのアクティブデータベース横断で同名イベントを発火(例:「フェーズ変更」ブロードキャスト):
foreach (var e in GameEventManager.Instance.GetGameEvents("OnPhaseStart"))
e.Raise();
カテゴリフィルタを併用してブロードキャストの範囲を絞れます:
foreach (var e in GameEventManager.Instance.GetGameEvents("OnTurnStart", "AI"))
e.Raise();
4. よくある落とし穴
落とし穴:リファクタ後に GUID 検索が null を返す
原因: イベントが削除されたか、未登録のデータベースに移動した。GUID はアセットのライフタイム中は安定ですが、アセット自体がなくなれば GUID も無効です。
対策: ロード時に GUID を検証し、欠損をログに残す。マイグレーションでは oldGuid → newGuid のルックアップを構築する。
落とし穴:非アクティブデータベースのイベントが見えない
原因: SetDatabaseActive(db, false) がデータベースのイベントをクエリ結果から消す。
対策: 非アクティブ内容を内省したいなら、一時的にアクティブ化 → クエリ → 再度非アクティブ化。非アクティブイベントがクエリで取れることを前提にしないこと。
落とし穴:名前検索が暗黙に間違ったイベントを返す
原因: カテゴリやデータベースをまたいで同名のイベントが複数あり、GetFirstGameEventByName が想定外のものを選んだ。
対策: 曖昧さがあり得る場面では必ず category フィルタを渡す。完全な安全性を求めるなら GUID を使う。すべての一致が欲しければ複数形の GetGameEvents を使う。
落とし穴:型アリティ不一致で 0 が返る
原因: GetGameEvents<int>("OnJump") が 0 を返すのは、OnJump が void イベントであり GameEvent<int> ではないから。
対策: これは設計どおり —— 厳密な型フィルタにより型をまたぐ誤マッチを防ぎます。任意の型でよいならジェネリック引数を外す。
落とし穴:シングルトンでないスクリプトの Awake でクエリ
原因: GameEventManager.Instance 自身の Awake が完了しておらず、登録済みデータベースが初期化されていない可能性がある。
対策: Start 以降でクエリする。ドメインロード時のクエリには RuntimeInitializeOnLoadMethod(SubsystemRegistration) をフックする。
落とし穴:毎フレーム再クエリ
原因: クエリ API をプロパティ参照のように扱う —— Update 内で GetFirstGameEventByName を呼んでいる。
対策: 解決済みの GameEvent 参照を一度だけキャッシュ(例:Start 内、またはデータベース登録後)し、以降は再利用する。クエリはデータベースリストを走査します —— 安価ではあるが、ゼロコストではありません。