20 KiB
34 · 事件链系统(Event Chain System)
命名空间
BaseGames.EventChain
所属文档集 ← 返回索引 · 总览
依赖BaseGames.Core.Events·BaseGames.Progression·BaseGames.World(SaveManager)·BaseGames.Dialogue(可选)
目录
- 系统总览
- EventChainSO — 链式数据
- ChainCondition — 触发条件
- ChainAction — 执行动作
- EventChainManager
- SaveData 集成
- 典型用例
- 事件频道
- 编辑器友好设计
1. 系统总览
事件链系统解决"游戏世界状态级联变化"的问题:击败 Boss → 开启大门 → 更新地图 → 改变 NPC 台词 → 触发过场动画。这些联动如果硬编码在各系统中会产生高耦合;事件链系统将其外置为纯数据配置,所有逻辑通过 SO 驱动。
事件链系统职责:
├─ EventChainSO → 描述"当 Condition 满足时,依次执行 Actions"
├─ ChainCondition → 抽象基类,多种内置实现(事件触发/标志位/数值比较)
├─ ChainAction → 抽象基类,多种内置实现(开门/切换NPC对话/触发过场)
├─ EventChainManager → 运行时监听事件,评估链条,按序执行动作
└─ ChainStateRegistry → 追踪每条链的执行状态(未触发/执行中/已完成)
零耦合原则:EventChainManager 通过 SO 事件频道感知世界变化,通过 ChainAction 的接口方法触发目标系统,不持有任何具体系统的直接引用。
2. EventChainSO — 链式数据
[CreateAssetMenu(menuName = "EventChain/EventChain")]
public class EventChainSO : ScriptableObject
{
[Header("基础")]
public string chainId; // 全局唯一,如 "Chain_BossForest_Defeated"
public bool repeatable; // false = 只触发一次(触发后 SaveData 记录)
public float actionDelay = 0f; // 各 action 之间的延迟(秒),0 = 并发执行
[Header("触发条件(全部满足才触发)")]
public ChainCondition[] conditions;
[Header("执行动作(顺序执行)")]
public ChainAction[] actions;
}
3. ChainCondition — 触发条件
/// <summary>
/// 事件链触发条件基类。
/// EventChainManager 调用 IsMet() 评估,调用 Register/Unregister 订阅相关事件。
/// </summary>
public abstract class ChainCondition : ScriptableObject
{
public abstract void Register(EventChainManager manager);
public abstract void Unregister(EventChainManager manager);
public abstract bool IsMet();
}
3.1 内置条件类型
| 条件 SO 类 | 参数 | 触发时机 | 示例 |
|---|---|---|---|
BossDefeatedCondition |
bossId: string |
OnBossDefeated 事件 |
Boss_Forest 击败后 |
FlagSetCondition |
flagId: string |
持续评估 | 某个全局标志位已设置 |
AbilityUnlockedCondition |
abilityType: AbilityType |
OnAbilityUnlocked 事件 |
获得冲刺能力后 |
CollectibleCollectedCondition |
itemId: string |
OnCollectiblePickedUp 事件 |
拾取关键道具后 |
RoomEnteredCondition |
sceneName: string |
OnRoomEntered 事件 |
玩家进入特定房间 |
DialogueCompletedCondition |
npcId, sequenceId |
OnDialogueCompleted 事件 |
完成某段 NPC 对话 |
ChainCompletedCondition |
chainId: string |
OnChainCompleted 事件 |
另一条事件链完成后 |
3.2 示例:BossDefeatedCondition
[CreateAssetMenu(menuName = "EventChain/Condition/BossDefeated")]
public class BossDefeatedCondition : ChainCondition
{
public string bossId;
bool _met;
public override void Register(EventChainManager m) => m.OnBossDefeated += Check;
public override void Unregister(EventChainManager m) => m.OnBossDefeated -= Check;
public override bool IsMet() => _met;
void Check(string id) { if (id == bossId) _met = true; }
}
4. ChainAction — 执行动作
/// <summary>
/// 事件链执行动作基类。
/// ExecuteAsync 可以是即时的或协程(如等待过场动画播放完)。
/// </summary>
public abstract class ChainAction : ScriptableObject
{
/// <returns>Coroutine IEnumerator(可 yield 等待)</returns>
public abstract IEnumerator ExecuteAsync(MonoBehaviour runner);
}
4.1 内置动作类型
| 动作 SO 类 | 参数 | 效果 |
|---|---|---|
OpenDoorAction |
doorId: string |
触发 OnDoorOpened.Raise(doorId) |
SetFlagAction |
flagId: string, value: bool |
设置全局标志位并写 SaveData |
UpdateMapAction |
regionId: string |
将区域标记为可见(地图联动) |
PlayCutsceneAction |
cutsceneId: string |
触发 CutsceneManager.Play(),等待播放完成 |
ChangeNPCDialogueAction |
npcId, newSequenceId |
更换 NPC 的当前对话 SO |
SpawnObjectAction |
prefab: GameObject, position |
在指定位置生成对象 |
WaitAction |
seconds: float |
纯等待(过场间隙) |
RaiseEventAction |
eventChannelSO |
触发任意 VoidEventChannelSO |
UnlockAbilityAction |
abilityType: AbilityType |
向玩家授予新能力 |
PlayAudioAction |
audioEventSO |
播放 BGM/SFX 切换 |
4.2 示例:OpenDoorAction
[CreateAssetMenu(menuName = "EventChain/Action/OpenDoor")]
public class OpenDoorAction : ChainAction
{
public string doorId;
[SerializeField] StringEventChannelSO _onDoorOpened;
public override IEnumerator ExecuteAsync(MonoBehaviour runner)
{
_onDoorOpened.Raise(doorId);
yield break; // 即时执行,不阻塞后续动作
}
}
4.3 示例:PlayCutsceneAction(阻塞直到播完)
[CreateAssetMenu(menuName = "EventChain/Action/PlayCutscene")]
public class PlayCutsceneAction : ChainAction
{
public string cutsceneId;
[SerializeField] CutsceneEventChannelSO _onPlayCutscene;
public override IEnumerator ExecuteAsync(MonoBehaviour runner)
{
bool done = false;
_onPlayCutscene.Raise(cutsceneId, () => done = true); // 完成回调
yield return new WaitUntil(() => done);
}
}
5. EventChainManager
namespace BaseGames.EventChain
{
public class EventChainManager : MonoBehaviour
{
[Header("所有事件链")]
[SerializeField] EventChainSO[] _chains;
[Header("事件频道(中继)")]
[SerializeField] StringEventChannelSO _onBossDefeated;
[SerializeField] StringEventChannelSO _onCollectiblePickedUp;
[SerializeField] IntEventChannelSO _onAbilityUnlocked;
[SerializeField] StringEventChannelSO _onRoomEntered;
[SerializeField] StringEventChannelSO _onDialogueCompleted;
// 中继事件,供 ChainCondition 订阅
public event Action<string> OnBossDefeated;
public event Action<string> OnCollectiblePickedUp;
public event Action<int> OnAbilityUnlocked;
public event Action<string> OnRoomEntered;
public event Action<string> OnDialogueCompleted;
// 已完成链 ID(本帧可能触发 ChainCompletedCondition)
public event Action<string> OnChainCompleted;
readonly HashSet<string> _completedChains = new();
void Awake()
{
// 从 SaveData 加载已完成链
foreach (var id in SaveManager.Instance.GetCompletedChains())
_completedChains.Add(id);
}
void OnEnable()
{
_onBossDefeated.OnEventRaised += id => { OnBossDefeated?.Invoke(id); EvaluateAll(); };
_onCollectiblePickedUp.OnEventRaised += id => { OnCollectiblePickedUp?.Invoke(id); EvaluateAll(); };
_onAbilityUnlocked.OnEventRaised += v => { OnAbilityUnlocked?.Invoke(v); EvaluateAll(); };
_onRoomEntered.OnEventRaised += id => { OnRoomEntered?.Invoke(id); EvaluateAll(); };
_onDialogueCompleted.OnEventRaised += id => { OnDialogueCompleted?.Invoke(id); EvaluateAll(); };
foreach (var chain in _chains)
foreach (var cond in chain.conditions)
cond.Register(this);
}
void OnDisable()
{
foreach (var chain in _chains)
foreach (var cond in chain.conditions)
cond.Unregister(this);
}
void EvaluateAll()
{
foreach (var chain in _chains)
{
if (!chain.repeatable && _completedChains.Contains(chain.chainId))
continue; // 一次性链已完成,跳过
if (Array.TrueForAll(chain.conditions, c => c.IsMet()))
StartCoroutine(ExecuteChain(chain));
}
}
IEnumerator ExecuteChain(EventChainSO chain)
{
// 标记为进行中(防止重入)
if (!chain.repeatable)
_completedChains.Add(chain.chainId);
foreach (var action in chain.actions)
{
yield return action.ExecuteAsync(this);
if (chain.actionDelay > 0f)
yield return new WaitForSeconds(chain.actionDelay);
}
// 链完成
SaveManager.Instance.SetChainCompleted(chain.chainId);
OnChainCompleted?.Invoke(chain.chainId);
}
}
}
6. SaveData 集成
在 31_SaveDataSchema_Unified.md 基础上新增 eventChains 字段:
"eventChains": {
"completedChains": [
"Chain_BossForest_Defeated",
"Chain_Forest_DoorOpened"
],
"flags": {
"ForestBossDefeated": true,
"TownNPCMetFirstTime": true,
"AbyssUnlocked": false
}
}
// SaveManager 扩展
public IEnumerable<string> GetCompletedChains()
=> _saveData.eventChains.completedChains;
public void SetChainCompleted(string chainId)
{
if (!_saveData.eventChains.completedChains.Contains(chainId))
{
_saveData.eventChains.completedChains.Add(chainId);
WriteDirty();
}
}
public bool GetFlag(string flagId)
=> _saveData.eventChains.flags.TryGetValue(flagId, out bool v) && v;
public void SetFlag(string flagId, bool value)
{
_saveData.eventChains.flags[flagId] = value;
WriteDirty();
}
7. 典型用例
7.1 击败 Boss → 开门 → 更新地图 → 改变 NPC 台词
资产:Chain_BossForest_Defeated.asset
chainId: "Chain_BossForest_Defeated"
repeatable: false
actionDelay: 0.5s
conditions:
[0] BossDefeatedCondition { bossId = "Boss_Forest" }
actions:
[0] WaitAction { seconds = 1.5 } ← 等待 Boss 死亡演出
[1] PlayCutsceneAction { cutsceneId = "CS_ForestBossDefeated" }
[2] OpenDoorAction { doorId = "Door_Forest_ToRuins" }
[3] UpdateMapAction { regionId = "Ruins" }
[4] SetFlagAction { flagId = "ForestBossDefeated", value = true }
[5] ChangeNPCDialogueAction { npcId = "NPC_Elder",
newSequenceId = "Dialogue_Elder_PostForestBoss" }
[6] PlayAudioAction { audioEventSO = BGMSnapshot_Victory }
7.2 首次进入区域 → 触发介绍过场
资产:Chain_FirstEnterCave.asset
chainId: "Chain_FirstEnterCave"
repeatable: false
conditions:
[0] RoomEnteredCondition { sceneName = "Room_Cave_01" }
actions:
[0] PlayCutsceneAction { cutsceneId = "CS_CaveIntro" }
7.3 收集所有魅力 → 解锁隐藏区域
资产:Chain_AllCharmsCollected.asset
chainId: "Chain_AllCharmsCollected"
repeatable: false
conditions:
[0] FlagSetCondition { flagId = "AllCharmsCollected" } ← 由 EquipmentManager 设置
actions:
[0] SpawnObjectAction { prefab = SecretDoor_Prefab, position = (45, -12) }
[1] RaiseEventAction { eventChannelSO = OnSecretUnlocked }
7.4 完成对话 → 解锁商店新货物
资产:Chain_NPCShopUnlock.asset
chainId: "Chain_NPCShopUnlock"
repeatable: false
conditions:
[0] DialogueCompletedCondition { npcId = "NPC_Merchant", sequenceId = "Dial_Merchant_Quest01" }
actions:
[0] SetFlagAction { flagId = "ShopStock_Tier2_Unlocked", value = true }
8. 事件频道
| 频道资产 | 类型 | 发布方 | 主要订阅方 |
|---|---|---|---|
OnChainCompleted.asset |
StringEventChannelSO |
EventChainManager |
其他 ChainCondition(链间依赖) |
OnDoorOpened.asset |
StringEventChannelSO |
OpenDoorAction |
DoorController(物理开门动画) |
OnFlagChanged.asset |
StringEventChannelSO |
SetFlagAction |
InteractableNPC(条件对话) |
OnSecretUnlocked.asset |
VoidEventChannelSO |
链中 RaiseEventAction |
隐藏区域管理器 |
9. 编辑器友好设计
EventChain Visualizer(EditorWindow)
菜单 Zeling / Tools / EventChain Visualizer,展示所有链的当前状态:
┌─ EventChain Visualizer ─────────────────────────────────────┐
│ ● Chain_BossForest_Defeated [✅ 已完成] │
│ ● Chain_FirstEnterCave [✅ 已完成] │
│ ● Chain_AllCharmsCollected [⏳ 条件未满足] │
│ └ FlagSetCondition: AllCharmsCollected → false │
│ ● Chain_NPCShopUnlock [⏳ 条件未满足] │
│ └ DialogueCompleted: NPC_Merchant/Dial_Merchant_Quest01 │
│ │
│ [重置全部] [模拟触发 ___________] │
└─────────────────────────────────────────────────────────────┘
新建事件链 SOP
- 右键
Assets/Data/EventChains/→Create → EventChain/EventChain - 命名规范:
Chain_{触发者}_{效果}.asset - 按需从
Create → EventChain/Condition/创建条件 SO - 按需从
Create → EventChain/Action/创建动作 SO - 将链 SO 拖入
EventChainManager._chains数组 - 在 EventChain Visualizer 中模拟验证
动作执行时序 Gizmos
在 Scene 视图中,SpawnObjectAction 的 position 字段显示为黄色菱形 Gizmo,辅助关卡设计师定位。
10. 链执行诊断与失败排查
事件链触发失败是关卡设计中最常见的调试场景。以下提供系统化诊断手段。
10.1 EventChainManager 诊断日志
EventChainManager 在以下时机输出带 [EventChain] 前缀的 Debug 日志(仅在 UNITY_EDITOR || DEVELOPMENT_BUILD 下有效):
// EventChainManager 内部日志钩子(已内置,无需手动添加)
void EvaluateAll()
{
foreach (var chain in _chains)
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
if (!chain.repeatable && _completedChains.Contains(chain.chainId))
{
Debug.Log($"[EventChain] Skip (already completed): {chain.chainId}");
continue;
}
#else
if (!chain.repeatable && _completedChains.Contains(chain.chainId)) continue;
#endif
bool allMet = Array.TrueForAll(chain.conditions, c => c.IsMet());
#if UNITY_EDITOR || DEVELOPMENT_BUILD
if (!allMet)
{
var unmet = chain.conditions.Where(c => !c.IsMet())
.Select(c => c.GetType().Name);
Debug.Log($"[EventChain] Blocked '{chain.chainId}': unmet → {string.Join(", ", unmet)}");
}
#endif
if (allMet)
StartCoroutine(ExecuteChain(chain));
}
}
IEnumerator ExecuteChain(EventChainSO chain)
{
Debug.Log($"[EventChain] START '{chain.chainId}'");
// ...(原有逻辑)
foreach (var action in chain.actions)
{
Debug.Log($"[EventChain] ► Action: {action.GetType().Name}");
yield return action.ExecuteAsync(this);
// ...
}
Debug.Log($"[EventChain] DONE '{chain.chainId}'");
}
10.2 EventChain Visualizer 诊断流程
┌─ EventChain Visualizer ─────────────────────────────────────────────┐
│ [运行时模式] 当前帧: 1234 存档槽: 0 │
│ │
│ ● Chain_BossForest_Defeated [✅ 已完成] │
│ ● Chain_FirstEnterCave [✅ 已完成] │
│ ● Chain_AllCharmsCollected [⏳ 条件未满足] │
│ └ FlagSetCondition: AllCharmsCollected → ✗ false │
│ ● Chain_NPCShopUnlock [⏳ 条件未满足] │
│ └ DialogueCompleted: NPC_Merchant/Dial_Merchant_Quest01 → ✗ │
│ ● Chain_HiddenRoom_Unlock [🔄 执行中] Action[2]/4 │
│ │
│ [强制触发 Chain_AllCharmsCollected] [重置全部] [导出状态 JSON] │
└─────────────────────────────────────────────────────────────────────┘
诊断步骤(链不触发时的标准流程):
| 步骤 | 操作 | 目的 |
|---|---|---|
| 1 | 打开 EventChain Visualizer | 查看链当前状态 |
| 2 | 展开目标链的条件折叠 | 确认哪个 Condition 未满足 |
| 3 | 点击"强制触发"验证 Actions | 排除 Condition 问题,隔离 Action 问题 |
| 4 | 查看 Console 中 [EventChain] 日志 |
确认链是否进入 EvaluateAll 评估 |
| 5 | 检查链是否在 Manager._chains 数组 | 遗漏注册是最常见原因 |
| 6 | 检查 repeatable = false 且已完成 |
一次性链不会重复触发 |
10.3 常见链失败原因速查
| 症状 | 最可能原因 | 修复方向 |
|---|---|---|
| 链完全不触发 | ① 未加入 Manager._chains ② 相关事件频道未连线(SO 字段为空) | 检查 Manager Inspector 和 SO 引用 |
| 条件一直未满足 | FlagSetCondition 的 flagId 拼写与 SetFlagAction 不一致 |
使用 AddressKeys 或常量集中定义 flagId,避免手写字符串 |
| Actions 执行一半停止 | PlayCutsceneAction.ExecuteAsync 回调未触发(done = false 永远不变) |
检查 CutsceneManager 是否正确调用完成回调 |
| 链触发两次 | repeatable = true 且触发事件会多次广播 |
设为 false 或在 Action 中添加防重入检查 |
| 链完成但 SaveData 未记录 | SaveManager.SetChainCompleted() 内部 WriteDirty() 未被调用 |
检查 SaveManager 的 WriteDirty 逻辑 |
10.4 flagId 集中定义规范
为避免 flagId 字符串散落在多个 SO 中难以追查,所有全局标志位 ID 在专用静态类中集中声明:
// Assets/Scripts/Core/GameFlags.cs
public static class GameFlags
{
// Boss
public const string ForestBossDefeated = "ForestBossDefeated";
public const string CaveBossDefeated = "CaveBossDefeated";
// NPC 状态
public const string TownNPCMetFirstTime = "TownNPCMetFirstTime";
public const string MerchantQuest01Done = "MerchantQuest01Done";
// 隐藏区域
public const string AllCharmsCollected = "AllCharmsCollected";
public const string HiddenRoomUnlocked = "HiddenRoomUnlocked";
// 商店
public const string ShopStockTier2Unlocked = "ShopStock_Tier2_Unlocked";
}
规范:SetFlagAction、FlagSetCondition、SetDebugFlag 一律引用 GameFlags.* 常量,不手写字符串。