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

20 KiB
Raw Permalink Blame History

34 · 事件链系统Event Chain System

命名空间 BaseGames.EventChain
所属文档集 ← 返回索引 · 总览
依赖 BaseGames.Core.Events · BaseGames.Progression · BaseGames.WorldSaveManager· BaseGames.Dialogue(可选)


目录

  1. 系统总览
  2. EventChainSO — 链式数据
  3. ChainCondition — 触发条件
  4. ChainAction — 执行动作
  5. EventChainManager
  6. SaveData 集成
  7. 典型用例
  8. 事件频道
  9. 编辑器友好设计

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 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 视图中,SpawnObjectActionposition 字段显示为黄色菱形 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";
}

规范SetFlagActionFlagSetConditionSetDebugFlag 一律引用 GameFlags.* 常量,不手写字符串。