Files
zeling_v2/Docs/Design/34_EventChainSystem.md
2026-05-08 11:04:00 +08:00

541 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
/// <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
```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
/// <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
```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<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` 字段:
```json
"eventChains": {
"completedChains": [
"Chain_BossForest_Defeated",
"Chain_Forest_DoorOpened"
],
"flags": {
"ForestBossDefeated": true,
"TownNPCMetFirstTime": true,
"AbyssUnlocked": false
}
}
```
```csharp
// 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 VisualizerEditorWindow
菜单 `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.*` 常量,不手写字符串。