# 34 · 事件链系统(Event Chain System)
> **命名空间** `BaseGames.EventChain`
> **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md)
> **依赖** `BaseGames.Core.Events` · `BaseGames.Progression` · `BaseGames.World`(SaveManager)· `BaseGames.Dialogue`(可选)
---
## 目录
1. [系统总览](#1-系统总览)
2. [EventChainSO — 链式数据](#2-eventchainsо--链式数据)
3. [ChainCondition — 触发条件](#3-chaincondition--触发条件)
4. [ChainAction — 执行动作](#4-chainaction--执行动作)
5. [EventChainManager](#5-eventchainmanager)
6. [SaveData 集成](#6-savedata-集成)
7. [典型用例](#7-典型用例)
8. [事件频道](#8-事件频道)
9. [编辑器友好设计](#9-编辑器友好设计)
---
## 1. 系统总览
事件链系统解决"游戏世界状态级联变化"的问题:击败 Boss → 开启大门 → 更新地图 → 改变 NPC 台词 → 触发过场动画。这些联动如果硬编码在各系统中会产生高耦合;事件链系统将其外置为纯数据配置,所有逻辑通过 SO 驱动。
```
事件链系统职责:
├─ EventChainSO → 描述"当 Condition 满足时,依次执行 Actions"
├─ ChainCondition → 抽象基类,多种内置实现(事件触发/标志位/数值比较)
├─ ChainAction → 抽象基类,多种内置实现(开门/切换NPC对话/触发过场)
├─ EventChainManager → 运行时监听事件,评估链条,按序执行动作
└─ ChainStateRegistry → 追踪每条链的执行状态(未触发/执行中/已完成)
```
**零耦合原则**:`EventChainManager` 通过 SO 事件频道感知世界变化,通过 `ChainAction` 的接口方法触发目标系统,不持有任何具体系统的直接引用。
---
## 2. EventChainSO — 链式数据
```csharp
[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 — 触发条件
```csharp
///
/// 事件链触发条件基类。
/// EventChainManager 调用 IsMet() 评估,调用 Register/Unregister 订阅相关事件。
///
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
```csharp
[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 — 执行动作
```csharp
///
/// 事件链执行动作基类。
/// ExecuteAsync 可以是即时的或协程(如等待过场动画播放完)。
///
public abstract class ChainAction : ScriptableObject
{
/// Coroutine IEnumerator(可 yield 等待)
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
```csharp
[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(阻塞直到播完)
```csharp
[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
```csharp
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 OnBossDefeated;
public event Action OnCollectiblePickedUp;
public event Action OnAbilityUnlocked;
public event Action OnRoomEntered;
public event Action OnDialogueCompleted;
// 已完成链 ID(本帧可能触发 ChainCompletedCondition)
public event Action OnChainCompleted;
readonly HashSet _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` 字段:
```json
"eventChains": {
"completedChains": [
"Chain_BossForest_Defeated",
"Chain_Forest_DoorOpened"
],
"flags": {
"ForestBossDefeated": true,
"TownNPCMetFirstTime": true,
"AbyssUnlocked": false
}
}
```
```csharp
// SaveManager 扩展
public IEnumerable 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
1. 右键 `Assets/Data/EventChains/` → `Create → EventChain/EventChain`
2. 命名规范:`Chain_{触发者}_{效果}.asset`
3. 按需从 `Create → EventChain/Condition/` 创建条件 SO
4. 按需从 `Create → EventChain/Action/` 创建动作 SO
5. 将链 SO 拖入 `EventChainManager._chains` 数组
6. 在 EventChain Visualizer 中模拟验证
### 动作执行时序 Gizmos
在 Scene 视图中,`SpawnObjectAction` 的 `position` 字段显示为黄色菱形 Gizmo,辅助关卡设计师定位。
---
## 10. 链执行诊断与失败排查
事件链触发失败是关卡设计中最常见的调试场景。以下提供系统化诊断手段。
### 10.1 EventChainManager 诊断日志
`EventChainManager` 在以下时机输出带 `[EventChain]` 前缀的 Debug 日志(仅在 `UNITY_EDITOR || DEVELOPMENT_BUILD` 下有效):
```csharp
// 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 在专用静态类中集中声明:
```csharp
// 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.*` 常量,不手写字符串。