跳到主要内容

数据库管理与运行时查询

静态工作流通过 [SerializeField] GameEvent 把事件和脚本绑死。这对场景内逻辑非常合适,但一旦事件落到动态内容边界之后 —— Addressables、DLC、Mod、存档/读档系统、不能挂序列化字段的纯 C# 领域代码 —— 就行不通了。

GameEventManager.Instance 提供了两组互补的 API 来打通这层障碍:

  • 数据库生命周期 —— 运行时 Register / Unregister / SetActive 数据库
  • 运行时查询 —— 按 GUID名称分类类型 查找事件,无需直接资产引用

两者结合,让任意代码(包括非 MonoBehaviour)都能把事件作为数据查找使用,而不是作为预连线的引用。


🎯 什么时候用

场景推荐做法
场景内 MonoBehaviour 上的逻辑静态工作流 —— [SerializeField] GameEvent
纯 C# 类 / 服务层 / 领域层运行时查询,按 GUID 或名称
存档/读档 —— 用持久化的数据再次触发事件运行时查询GUID(身份稳定)
场景/DLC/Mod 在运行时新增事件数据库生命周期 —— RegisterDatabase
暂停/过场动画时静音整组事件数据库生命周期 —— SetDatabaseActive(false)
插件需要按命名规则匹配的所有事件运行时查询 —— GetGameEvents(name)

如果你发现自己正在写一个 Dictionary<string, GameEvent> 缓存,并在场景加载时手动填充 —— 你其实是在重新发明 Query API。直接用现成的就好。


1. 数据库生命周期

GameEventDatabase 是一个 ScriptableObject,里面包含一组 GameEvent 子资产。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 条目。重复注册同一个数据库是安全的空操作。UnregisterDatabase 会清理绑定和运行时回调;对 null 或未注册的数据库调用也是安全的。

Active vs Inactive

SetDatabaseActive(db, false) 让数据库保持注册状态,但其事件在 Raise静默无操作。再次调用 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 元数据)。

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,可直接 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 事件)
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 能扛住代码重构、资产移动甚至重命名 —— 只有删除事件或注销其所属数据库才会让它失效。

配方: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 代码也能通过 manager 驱动事件:

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

坑:泛型 arity 不匹配返回 0

原因: GetGameEvents<int>("OnJump") 返回 0,因为 OnJump 是 void 事件不是 GameEvent<int>

解决: 这是设计如此 —— 严格类型过滤防止跨类型误中。如果你确实想要任意类型,去掉泛型参数。

坑:在非单例脚本的 Awake 里查询

原因: GameEventManager.Instance 自身的 Awake 可能还没跑完,已注册数据库尚未初始化。

解决:Start 或更晚阶段查询。需要域加载阶段查询时,挂 RuntimeInitializeOnLoadMethod(SubsystemRegistration)

坑:每帧重复查询

原因: 把查询 API 当属性来用 —— 在 Update 里调 GetFirstGameEventByName

解决: 解析一次后缓存 GameEvent 引用(例如在 Start 里或数据库注册完成后),后续直接复用。查询会遍历数据库列表 —— 便宜,但不是零成本。