メインコンテンツまでスキップ

データベース管理とランタイムクエリ

静的ワークフローでは [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. データベース・ライフサイクル

GameEventDatabaseGameEvent サブアセットのリストを持つ 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 内、またはデータベース登録後)し、以降は再利用する。クエリはデータベースリストを走査します —— 安価ではあるが、ゼロコストではありません。