数据库管理与运行时查询
静态工作流通过 [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 里或数据库注册完成后),后续直接复用。查询会遍历数据库列表 —— 便宜,但不是零成本。