Files
zeling_v2/Docs/Architecture/14_NarrativeModule.md
2026-05-08 11:04:00 +08:00

47 KiB
Raw Permalink Blame History

14 · 叙事模块

命名空间 BaseGames.DialogueBaseGames.CutsceneBaseGames.EventChainBaseGames.WorldIInteractable、WorldStateRegistry
程序集 BaseGames.DialogueBaseGames.CutsceneBaseGames.EventChain
路径 Assets/Scripts/Dialogue/Assets/Scripts/Cutscene/Assets/Scripts/EventChain/
依赖 BaseGames.Core.EventsBaseGames.InputAction Map 切换)、BaseGames.WorldSaveManager


目录

  1. IInteractable 接口
  2. InteractionPromptController交互提示 UI
  3. DialogueLineSO 与 DialogueSequenceSO
  4. DialogueManager
  5. DialogueUI对话框组件
  6. InteractableNPC
  7. NarrativeNPC条件对话 NPC
  8. WorldStateRegistry
  9. EventChain世界事件链
  10. EventChainManager
  11. CutsceneManagerTimeline 封装)
  12. 叙事事件频道清单

1. IInteractable 接口

// 路径: Assets/Scripts/World/IInteractable.cs
namespace BaseGames.World
{
    public interface IInteractable
    {
        bool   CanInteract { get; }
        string InteractPrompt { get; }
        void   Interact(Transform player);
        void   OnPlayerEnterRange(Transform player);
        void   OnPlayerExitRange();
    }
}

已实现 IInteractable 的组件SavePointInteractableNPCShopNPCAbilityUnlockSign(告示牌)


2. InteractionPromptController交互提示 UI

当玩家进入 IInteractable 的交互范围时,在物件上方显示交互提示图标:

// 路径: Assets/Scripts/Dialogue/InteractionPromptController.cs
// 挂载:每个 IInteractable GameObject 下挂载一个子节点Prefab 实例),默认隐藏
public class InteractionPromptController : MonoBehaviour
{
    [SerializeField] GameObject _promptRoot;
    [SerializeField] Image      _icon;
    [SerializeField] Sprite     _keyboardIcon;
    [SerializeField] Sprite     _gamepadIcon;

    public void Show()
    {
        _promptRoot.SetActive(true);
        // 根据当前活跃输入设备切换图标
        bool isGamepad = InputSystem.devices.OfType<Gamepad>().Any(g => g.enabled);
        _icon.sprite = isGamepad ? _gamepadIcon : _keyboardIcon;
    }

    public void Hide() => _promptRoot.SetActive(false);
}

检测范围:独立的 CircleCollider2DTrigger半径约 1.5 单位,检测 Player 层,由 InteractableDetector 统一驱动 Show/Hide。


3. DialogueLineSO 与 DialogueSequenceSO

// 路径: Assets/Scripts/Dialogue/DialogueLineSO.cs
// 单行对话数据
[System.Serializable]
public class DialogueLine
{
    public string SpeakerNameKey;   // 本地化 Key""=无说话人)
    [TextArea(2, 6)]
    public string TextKey;          // 本地化 Key 或直接文本
    public Sprite PortraitSprite;   // 可选说话人头像
    public float  TypewriterDelay  = 0.03f;   // 每字符延迟(秒)
}

// 路径: Assets/Scripts/Dialogue/DialogueSequenceSO.cs
[CreateAssetMenu(menuName = "Dialogue/DialogueSequence")]
public class DialogueSequenceSO : ScriptableObject
{
    public string       sequenceId;          // 全局唯一
    public DialogueLine[] Lines;

    // 条件对话(按游戏进程选择)
    [System.Serializable]
    public struct ConditionalVariant
    {
        public string              ConditionFlag;   // WorldStateRegistry 中的 flag key
        public DialogueSequenceSO  Sequence;        // 满足条件时用此序列替换
    }
    public ConditionalVariant[] Variants;
}

资产路径Assets/ScriptableObjects/Dialogue/
命名规范DLG_{NpcId}_{Context}.asset


4. DialogueManager

// 路径: Assets/Scripts/Dialogue/DialogueManager.cs
public class DialogueManager : MonoBehaviour
{
    [SerializeField] private DialogueBox     _dialogueBox;   // Canvas_Overlay 下的 UI
    [SerializeField] private InputReaderSO   _inputReader;

    [Header("Event Channels")]
    [SerializeField] private VoidEventChannelSO _onDialogueStarted;
    [SerializeField] private VoidEventChannelSO _onDialogueEnded;
    [SerializeField] private StringEventChannelSO _onNpcDialogueCompleted;  // → EVT_NpcDialogueCompletednpcIdQuestManager 订阅)
    [SerializeField] private GameStateEventChannelSO _onGameStateChanged;

    public bool IsDialogueActive { get; private set; }   // ECS_Dialogue.Execute 使用
    private bool _skipRequested;

    // 由 InteractableNPC 调用(主动开始对话)
    public void StartDialogue(DialogueSequenceSO sequence);
    // 1. 若 IsDialogueActive → 返回
    // 2. 选择 ConditionalVariant查询 WorldStateRegistry
    // 3. _dialogueBox.Show()
    // 4. 切换 InputReader Action Map → UI
    // 5. _onDialogueStarted.Raise()
    // 6. StartCoroutine(PlaySequence(sequence))

    // 玩家按下 Submit 键时InputReaderSO.SubmitEvent
    private void OnSubmit() => _skipRequested = true;

    private IEnumerator PlaySequence(DialogueSequenceSO sequence)
    {
        foreach (var line in sequence.Lines)
        {
            _skipRequested = false;
            yield return _dialogueBox.TypeText(line.TextKey, line.TypewriterDelay);
            // 等待玩家按 Submit 继续
            yield return new WaitUntil(() => _skipRequested);
        }
        EndDialogue();
    }

    private void EndDialogue()
    {
        _dialogueBox.Hide();
        IsDialogueActive = false;
        // 恢复 Action Map → Gameplay
        _onDialogueEnded.Raise();
    }
}

5. DialogueUI对话框组件

挂载在 Canvas_Overlay 下的 DialogueBox 子对象(见 10_UISystem.md §2

// 路径: Assets/Scripts/Dialogue/DialogueUI.cs
public class DialogueUI : MonoBehaviour
{
    [SerializeField] GameObject  _rootPanel;
    [SerializeField] TMP_Text    _speakerNameText;
    [SerializeField] TMP_Text    _dialogueText;
    [SerializeField] GameObject  _speakerNamePanel;  // 无名称时隐藏整个名称框
    [SerializeField] GameObject  _continuePrompt;    // "▼" 图标,打字完成后显示
    [SerializeField] Image       _speakerPortrait;   // P1人物头像框

    Coroutine _typingCoroutine;
    DialogueLine _currentLine;
    public bool IsTyping { get; private set; }

    public void ShowLine(DialogueLine line)
    {
        _currentLine = line;
        _rootPanel.SetActive(true);
        _continuePrompt.SetActive(false);

        bool hasSpeaker = !string.IsNullOrEmpty(line.SpeakerNameKey);
        _speakerNamePanel.SetActive(hasSpeaker);
        if (hasSpeaker) _speakerNameText.text = line.SpeakerNameKey;

        if (_typingCoroutine != null) StopCoroutine(_typingCoroutine);
        _typingCoroutine = StartCoroutine(TypeLine(line));
    }

    IEnumerator TypeLine(DialogueLine line)
    {
        IsTyping = true;
        // 性能:使用 StringBuilder 避免每帧字符串分配O(n²) → O(n)
        var sb = new System.Text.StringBuilder(line.TextKey.Length);
        _dialogueText.text = "";
        foreach (char c in line.TextKey)
        {
            sb.Append(c);
            _dialogueText.SetText(sb);          // TMP SetText(StringBuilder) 零分配
            yield return new WaitForSecondsRealtime(line.TypewriterDelay);
        }
        IsTyping = false;
        _continuePrompt.SetActive(true);
    }

    public void SkipTyping()
    {
        if (_typingCoroutine != null) StopCoroutine(_typingCoroutine);
        _dialogueText.text = _currentLine?.TextKey ?? "";
        IsTyping = false;
        _continuePrompt.SetActive(true);
    }

    public void Hide() => _rootPanel.SetActive(false);
}

6. InteractableNPC

// 路径: Assets/Scripts/Dialogue/InteractableNPC.cs
public class InteractableNPC : MonoBehaviour, IInteractable
{
    [SerializeField] private string              _npcId;
    [SerializeField] private DialogueSequenceSO  _defaultDialogue;
    [SerializeField] private float               _interactRadius = 1.5f;

    private DialogueManager _dialogueManager;  // 通过场景中 Find 或注入

    // ── IInteractable ─────────────────────────────────────────
    public bool   CanInteract    => true;
    public string InteractPrompt => "对话";

    public void Interact(Transform player)
    {
        Interact_Internal(player);                             // 子类扩展钩子(如 QuestGiver
        _dialogueManager.StartDialogue(GetCurrentDialogue());  // 启动对话
    }

    /// <summary>子类覆盖此方法以在对话前注入额外逻辑(如接受/完成任务)。</summary>
    protected virtual void Interact_Internal(Transform player) { }

    /// <summary>子类覆盖此方法以根据游戏状态返回不同的对话 SO见 NarrativeNPC。</summary>
    protected virtual DialogueSequenceSO GetCurrentDialogue() => _defaultDialogue;

    public void OnPlayerEnterRange(Transform player) { }  // 交互提示 UI 由 InteractableDetector 统一发布
    public void OnPlayerExitRange() { }                   // 交互提示 UI 由 InteractableDetector 统一发布
}

7. NarrativeNPC条件对话 NPC

扩展 InteractableNPC,支持根据 WorldStateRegistry 标志动态选择对话版本:

// 路径: Assets/Scripts/Dialogue/NarrativeNPC.cs
public class NarrativeNPC : InteractableNPC
{
    [Header("台词版本集(从高到低优先级排列)")]
    [SerializeField] DialogueVersion[]   _dialogueVersions;
    [SerializeField] DialogueSequenceSO  _defaultDialogue;   // 无条件满足时的默认台词
    [SerializeField] WorldStateRegistry  _worldState;        // SO 注入

    protected virtual DialogueSequenceSO GetCurrentDialogue()
    {
        foreach (var version in _dialogueVersions)
        {
            if (version.CheckConditions(_worldState))
                return version.dialogue;
        }
        return _defaultDialogue;
    }
}

[System.Serializable]
public class DialogueVersion
{
    public string             versionLabel;    // 编辑器显示名(如"森林Boss击败后"
    public DialogueSequenceSO dialogue;
    public string[]           requiredFlags;   // 全部满足才激活此版本AND 关系)
    public string[]           blockedByFlags;  // 有任意一个 = 此版本不激活NOT 关系)

    public bool CheckConditions(WorldStateRegistry registry)
    {
        foreach (var f in requiredFlags)
            if (!registry.HasFlag(f)) return false;
        foreach (var f in blockedByFlags)
            if (registry.HasFlag(f)) return false;
        return true;
    }
}

8. WorldStateRegistry

全局世界状态注册表SO 形式,零耦合注入),存储所有已激活的世界状态标志。

P1 优化:二级命名空间索引,替代原单一 HashSet<string>,支持 DLC 子集按命名空间卸载,防止键名冲突:

// 路径: Assets/Scripts/World/Narrative/WorldStateRegistry.cs
namespace BaseGames.World
{
    [CreateAssetMenu(menuName = "Narrative/WorldStateRegistry")]
    public class WorldStateRegistry : ScriptableObject
    {
        // 二级索引namespace → flags
        // namespace 约定 = 区域/模块前缀,如 "Forest"、"DLC_Abyss"、"Quest"
        private readonly Dictionary<string, HashSet<string>> _nsFlags = new();

        // ── 写入 ──────────────────────────────────────────────────────────
        /// <summary>
        /// 设置标志。<br/>
        /// <paramref name="flagKey"/> 格式:<c>Namespace_Object_State</c>(如 <c>Boss_SpiderGuard_Defeated</c>)。
        /// Namespace 取第一段;若无下划线则归入 "Global"。
        /// </summary>
        public void SetFlag(string flagKey, bool value = true)
        {
            var (ns, local) = Split(flagKey);
            if (!_nsFlags.TryGetValue(ns, out var set))
                _nsFlags[ns] = set = new HashSet<string>();
            if (value) set.Add(local);
            else       set.Remove(local);
        }

        // ── 读取 ──────────────────────────────────────────────────────────
        /// <summary>查询标志(任意命名空间或全限定 key 均可查)。</summary>
        public bool HasFlag(string flagKey)
        {
            var (ns, local) = Split(flagKey);
            return _nsFlags.TryGetValue(ns, out var set) && set.Contains(local);
        }

        // ── 命名空间级操作DLC 卸载用)────────────────────────────────
        /// <summary>卸载指定命名空间的所有标志DLC 退出时调用)。</summary>
        public void ClearNamespace(string ns) => _nsFlags.Remove(ns);

        public IEnumerable<string> GetFlagsInNamespace(string ns)
            => _nsFlags.TryGetValue(ns, out var s) ? (IEnumerable<string>)s
                                                    : System.Array.Empty<string>();

        // ── 存档 I/O ──────────────────────────────────────────────────────
        /// <summary>从存档恢复(传入全限定 key 列表)。</summary>
        public void LoadFromSave(IEnumerable<string> flags)
        {
            _nsFlags.Clear();
            foreach (var f in flags) SetFlag(f);
        }

        /// <summary>序列化为全限定 key 列表(存档用)。</summary>
        public IEnumerable<string> GetAllFlags()
        {
            foreach (var (ns, set) in _nsFlags)
                foreach (var local in set)
                    yield return $"{ns}_{local}";
        }

        // ── 内部 ──────────────────────────────────────────────────────────
        private static (string ns, string local) Split(string key)
        {
            int idx = key.IndexOf('_');
            return idx > 0
                ? (key[..idx], key[(idx + 1)..])
                : ("Global", key);
        }
    }
}

标志命名规范<Namespace>_<Object>_<State>

Namespace 示例 key 说明
Boss Boss_SpiderGuard_Defeated Boss 击败状态
NPC NPC_MerchantA_Migrated NPC 状态变更
Quest Quest_ForestMystery_Completed 任务完成标记
World World_ForestBridge_Opened 世界对象状态
DLC_* DLC_Abyss_BossFirst_Defeated DLC 专属命名空间(卸载时可整体清除)
Global TutorialCompleted 无命名空间回退

9. EventChain世界事件链

// 路径: Assets/Scripts/EventChain/EventChainSO.cs
namespace BaseGames.EventChain
{
    // EventChainSO描述"当全部 Condition 满足时,依次执行 Actions"
    // 策划纯数据配置,无需程序员介入
    [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;
    }

    // ChainCondition 抽象基类Strategy + Observer 混合
    // Register/Unregister 向 EventChainManager 的中继 C# 事件挂钩,
    // IsMet() 被 EvaluateAll() 调用以检验是否满足触发条件
    public abstract class ChainCondition : ScriptableObject
    {
        public abstract void Register(EventChainManager manager);
        public abstract void Unregister(EventChainManager manager);
        public abstract bool IsMet();
    }

    // ── 内置条件实现 ──────────────────────────────────────────

    [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; }
    }

    [CreateAssetMenu(menuName = "EventChain/Condition/FlagSet")]
    public class FlagSetCondition : ChainCondition
    {
        public string flagId;
        public override void Register(EventChainManager m)   { }  // 无需订阅事件,持续轮询
        public override void Unregister(EventChainManager m) { }
        public override bool IsMet() => SaveManager.Instance.GetFlag(flagId);
    }

    [CreateAssetMenu(menuName = "EventChain/Condition/AbilityUnlocked")]
    public class AbilityUnlockedCondition : ChainCondition
    {
        public string abilityId;  // 匹配 EVT_AbilityUnlockedStringEventChannelSO传来的 abilityId
        bool _met;
        public override void Register(EventChainManager m)   => m.OnAbilityUnlocked   += Check;
        public override void Unregister(EventChainManager m) => m.OnAbilityUnlocked   -= Check;
        public override bool IsMet() => _met;
        void Check(string id) { if (id == abilityId) _met = true; }
    }

    [CreateAssetMenu(menuName = "EventChain/Condition/CollectibleCollected")]
    public class CollectibleCollectedCondition : ChainCondition
    {
        public string itemId;
        bool _met;
        public override void Register(EventChainManager m)   => m.OnCollectiblePickedUp += Check;
        public override void Unregister(EventChainManager m) => m.OnCollectiblePickedUp -= Check;
        public override bool IsMet() => _met;
        void Check(string id) { if (id == itemId) _met = true; }
    }

    [CreateAssetMenu(menuName = "EventChain/Condition/RoomEntered")]
    public class RoomEnteredCondition : ChainCondition
    {
        public string sceneName;
        bool _met;
        public override void Register(EventChainManager m)   => m.OnRoomEntered   += Check;
        public override void Unregister(EventChainManager m) => m.OnRoomEntered   -= Check;
        public override bool IsMet() => _met;
        void Check(string id) { if (id == sceneName) _met = true; }
    }

    [CreateAssetMenu(menuName = "EventChain/Condition/DialogueCompleted")]
    public class DialogueCompletedCondition : ChainCondition
    {
        public string npcId;
        public string sequenceId;  // 额外过滤OnDialogueCompleted 传递 npcId
        bool _met;
        public override void Register(EventChainManager m)   => m.OnDialogueCompleted += Check;
        public override void Unregister(EventChainManager m) => m.OnDialogueCompleted -= Check;
        public override bool IsMet() => _met;
        void Check(string id) { if (id == npcId) _met = true; }
    }

    [CreateAssetMenu(menuName = "EventChain/Condition/ChainCompleted")]
    public class ChainCompletedCondition : ChainCondition
    {
        public string chainId;
        bool _met;
        public override void Register(EventChainManager m)   => m.OnChainCompleted += Check;
        public override void Unregister(EventChainManager m) => m.OnChainCompleted -= Check;
        public override bool IsMet() => _met;
        void Check(string id) { if (id == chainId) _met = true; }
    }

    // ChainAction 抽象基类ExecuteAsync 可即时返回或协程等待
    public abstract class ChainAction : ScriptableObject
    {
        public abstract IEnumerator ExecuteAsync(MonoBehaviour runner);
    }

    // ── 内置动作实现 ──────────────────────────────────────────

    [CreateAssetMenu(menuName = "EventChain/Action/OpenDoor")]
    public class OpenDoorAction : ChainAction
    {
        public string doorId;
        [SerializeField] StringEventChannelSO _onDoorOpened;   // EVT_DoorOpened
        public override IEnumerator ExecuteAsync(MonoBehaviour runner)
        {
            _onDoorOpened.Raise(doorId);
            yield break;
        }
    }

    [CreateAssetMenu(menuName = "EventChain/Action/SetFlag")]
    public class SetFlagAction : ChainAction
    {
        public string flagId;
        public bool   value;
        [SerializeField] StringEventChannelSO _onFlagChanged;  // EVT_FlagChanged
        public override IEnumerator ExecuteAsync(MonoBehaviour runner)
        {
            SaveManager.Instance.SetFlag(flagId, value);
            _onFlagChanged.Raise(flagId);
            yield break;
        }
    }

    [CreateAssetMenu(menuName = "EventChain/Action/UpdateMap")]
    public class UpdateMapAction : ChainAction
    {
        public string regionId;
        public override IEnumerator ExecuteAsync(MonoBehaviour runner)
        {
            MapManager.Instance.RevealRegion(regionId);
            yield break;
        }
    }

    [CreateAssetMenu(menuName = "EventChain/Action/PlayCutscene")]
    public class PlayCutsceneAction : ChainAction
    {
        public string cutsceneId;
        [SerializeField] StringEventChannelSO _onPlayCutscene;  // → CutsceneManager.PlayById
        [SerializeField] VoidEventChannelSO   _onCutsceneEnded; // ← CutsceneManager 播完时 Raise
        public override IEnumerator ExecuteAsync(MonoBehaviour runner)
        {
            bool done = false;
            _onCutsceneEnded.OnEventRaised += OnDone;
            _onPlayCutscene.Raise(cutsceneId);
            yield return new WaitUntil(() => done);
            _onCutsceneEnded.OnEventRaised -= OnDone;
            void OnDone() => done = true;
        }
    }

    [CreateAssetMenu(menuName = "EventChain/Action/ChangeNPCDialogue")]
    public class ChangeNPCDialogueAction : ChainAction
    {
        public string npcId;
        public string newSequenceId;
        public override IEnumerator ExecuteAsync(MonoBehaviour runner)
        {
            NPCRegistry.Instance.SetDialogueSequence(npcId, newSequenceId);
            yield break;
        }
    }

    [CreateAssetMenu(menuName = "EventChain/Action/SpawnObject")]
    public class SpawnObjectAction : ChainAction
    {
        public GameObject prefab;
        public Vector3    position;
        public override IEnumerator ExecuteAsync(MonoBehaviour runner)
        {
            Object.Instantiate(prefab, position, Quaternion.identity);
            yield break;
        }
    }

    [CreateAssetMenu(menuName = "EventChain/Action/Wait")]
    public class WaitAction : ChainAction
    {
        public float seconds;
        public override IEnumerator ExecuteAsync(MonoBehaviour runner)
            => new WaitForSeconds(seconds) as IEnumerator;
    }

    [CreateAssetMenu(menuName = "EventChain/Action/RaiseEvent")]
    public class RaiseEventAction : ChainAction
    {
        [SerializeField] VoidEventChannelSO eventChannelSO;
        public override IEnumerator ExecuteAsync(MonoBehaviour runner)
        {
            eventChannelSO.Raise();
            yield break;
        }
    }

    [CreateAssetMenu(menuName = "EventChain/Action/UnlockAbility")]
    public class UnlockAbilityAction : ChainAction
    {
        public string abilityId;
        public override IEnumerator ExecuteAsync(MonoBehaviour runner)
        {
            PlayerStats.Instance.UnlockAbility(abilityId);
            yield break;
        }
    }

    [CreateAssetMenu(menuName = "EventChain/Action/PlayAudio")]
    public class PlayAudioAction : ChainAction
    {
        [SerializeField] StringEventChannelSO _onPlayBGM;  // EVT_PlayBGM
        public string bgmKey;
        public override IEnumerator ExecuteAsync(MonoBehaviour runner)
        {
            _onPlayBGM.Raise(bgmKey);
            yield break;
        }
    }
}

10. EventChainManager

// 路径: Assets/Scripts/EventChain/EventChainManager.cs
namespace BaseGames.EventChain
{
    public class EventChainManager : MonoBehaviour
    {
        [Header("所有事件链")]
        [SerializeField] EventChainSO[] _chains;

        [Header("事件频道(中继)")]
        [SerializeField] StringEventChannelSO  _onBossDefeated;        // EVT_EnemyDied (bossId)
        [SerializeField] StringEventChannelSO  _onCollectiblePickedUp; // EVT_CollectiblePickup
        [SerializeField] StringEventChannelSO  _onAbilityUnlocked;     // EVT_AbilityUnlockedStringEventChannelSO
        [SerializeField] StringEventChannelSO  _onRoomEntered;         // EVT_SceneLoaded
        [SerializeField] StringEventChannelSO  _onDialogueCompleted;   // EVT_NpcDialogueCompleted

        // 中继 C# 事件,供 ChainCondition.Register() 订阅
        public event Action<string> OnBossDefeated;
        public event Action<string> OnCollectiblePickedUp;
        public event Action<string> OnAbilityUnlocked;
        public event Action<string> OnRoomEntered;
        public event Action<string> OnDialogueCompleted;
        public event Action<string> OnChainCompleted;  // 链完成时广播 chainId供 ChainCompletedCondition

        readonly HashSet<string> _completedChains = new();

        void Awake()
        {
            // 从 SaveData 恢复已完成链 ID
            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     += id => { OnAbilityUnlocked?.Invoke(id);   EvaluateAll(); };
            _onRoomEntered.OnEventRaised         += id => { OnRoomEntered?.Invoke(id);       EvaluateAll(); };
            _onDialogueCompleted.OnEventRaised   += id => { OnDialogueCompleted?.Invoke(id); EvaluateAll(); };

            // 向每个 Condition 注册中继事件
            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.1 EventChain SaveData 集成

eventChains 字段已加入统一 SaveData 结构(参见 12_SaveModule.md

"eventChains": {
  "completedChains": ["Chain_BossForest_Defeated", "Chain_Forest_DoorOpened"],
  "flags": { "ForestBossDefeated": true, "AbyssUnlocked": false }
}
// SaveManager 扩展方法(参见 12_SaveModule.md §SaveData 结构)
public IEnumerable<string> GetCompletedChains();
public void SetChainCompleted(string chainId);
public bool GetFlag(string flagId);
public void SetFlag(string flagId, bool value);

11. CutsceneManager

// 路径: Assets/Scripts/Cutscene/CutsceneManager.cs
// Unity Timeline 封装
[RequireComponent(typeof(PlayableDirector))]
public class CutsceneManager : MonoBehaviour
{
    [SerializeField] private InputReaderSO _inputReader;

    private PlayableDirector _director;
    public bool IsPlaying => _director.state == PlayState.Playing;

    [Header("Event Channels")]
    [SerializeField] private VoidEventChannelSO _onCutsceneStarted;
    [SerializeField] private VoidEventChannelSO _onCutsceneEnded;

    private void Awake() => _director = GetComponent<PlayableDirector>();

    public void PlayCutscene(CutsceneSO cutscene)
    {
        if (cutscene == null) return;
        _director.playableAsset = cutscene.Timeline;

        // 应用 Track → GameObject 绑定
        foreach (var binding in cutscene.Bindings)
        {
            var track = cutscene.Timeline.GetOutputTrack(
                System.Array.FindIndex(cutscene.Bindings, b => b.trackName == binding.trackName));
            if (track != null && binding.target != null)
                _director.SetGenericBinding(track, binding.target);
        }

        _director.stopped += OnCutsceneStopped;
        _director.Play();
        _onCutsceneStarted.Raise();
        // 禁用 Gameplay 输入(切换到 UI Action Map
    }

    public void StopCutscene()
    {
        _director.Stop();
    }

    private void OnCutsceneStopped(PlayableDirector d)
    {
        _director.stopped -= OnCutsceneStopped;
        _onCutsceneEnded.Raise();
        // 恢复 Gameplay 输入
    }
}

11.5 CutsceneSO 与 CutsceneTrigger

// 路径: Assets/Scripts/Cutscene/CutsceneSO.cs
// 过场动画数据资产:定义一段完整的过场内容
[CreateAssetMenu(menuName = "Cutscene/Cutscene")]
public class CutsceneSO : ScriptableObject
{
    [Header("Identity")]
    public string        cutsceneId;       // 全局唯一,用于存档去重
    public string        displayName;      // 编辑器/UI 中显示的可读名称
    public bool          playOnlyOnce;     // true → 仅首次播放(后续触发跳过)
    public bool          isSkippable = true;  // 是否允许玩家跳过
    public Sprite        thumbnail;        // 过场预览图(美术库 / 剧情重放 UI 用)

    [Header("Timeline")]
    public TimelineAsset Timeline;         // Unity Timeline 资产

    [Header("Timeline Bindings")]
    // Track 与场景 GameObject 的绑定关系(避免 Director 硬引用场景对象)
    public CutsceneBinding[] Bindings;     // 数组长度 = Timeline track 数

    [Header("Camera")]
    public CinemachineBlendDefinition BlendIn;     // 进入过场时的 Cinemachine 混合
    public CinemachineBlendDefinition BlendOut;    // 退出过场时的混合

    [Header("Optional Dialogue Overlay")]
    // 过场中可叠加播放的对话序列Timeline 上添加 Marker 触发)
    public DialogueSequenceSO[] DialogueLayers;
}

/// <summary>将一条 Timeline Track通过名称索引绑定到运行时场景对象。</summary>
[Serializable]
public struct CutsceneBinding
{
    [Tooltip("Timeline Track 的名称(需与 PlayableDirector 内轨道名一致)")]
    public string   trackName;
    [Tooltip("绑定的目标对象;若为 null 则 CutsceneManager 会从场景中按 tag/name 查找")]
    public Object   target;       // UnityEngine.Object可以是 GameObject / Component / asset
}

// 路径: Assets/Scripts/Cutscene/CutsceneTrigger.cs
// 挂载在场景 GameObject 上,满足条件时自动触发或由玩家交互触发
public class CutsceneTrigger : MonoBehaviour, IInteractable
{
    public enum TriggerMode
    {
        OnEnter,       // 进入 Trigger 区域(对应 Design OnZoneEnter
        OnInteract,    // 玩家主动交互IInteractable
        OnSceneLoad,   // 场景加载完毕Start
        OnEvent,       // 订阅事件频道触发(配合 _triggerEventChannel
    }

    [SerializeField] private CutsceneSO              _cutscene;
    [SerializeField] private TriggerMode             _mode = TriggerMode.OnEnter;
    [SerializeField] private CutsceneManager         _cutsceneManager;
    [SerializeField] private VoidEventChannelSO      _triggerEventChannel;  // OnEvent 模式使用
    [SerializeField] private WorldStateRegistry      _worldState;           // SO 注入,用于记录/查询播放状态

    // ── IInteractableTriggerMode.OnInteract 模式使用)──────────────
    public bool   CanInteract    => _mode == TriggerMode.OnInteract;
    public string InteractPrompt => "查看";

    public void Interact(Transform player) => TriggerCutscene();
    public void OnPlayerEnterRange(Transform player) { }
    public void OnPlayerExitRange() { }

    private void OnEnable()
    {
        if (_mode == TriggerMode.OnEvent && _triggerEventChannel != null)
            _triggerEventChannel.OnEventRaised += TriggerCutscene;
    }

    private void OnDisable()
    {
        if (_mode == TriggerMode.OnEvent && _triggerEventChannel != null)
            _triggerEventChannel.OnEventRaised -= TriggerCutscene;
    }

    // ── Zone Enter 触发 ──────────────────────────────────────────────
    private void OnTriggerEnter2D(Collider2D other)
    {
        if (_mode != TriggerMode.OnEnter) return;
        if (!other.CompareTag("Player")) return;
        TriggerCutscene();
    }

    // ── 场景加载触发 ─────────────────────────────────────────────────
    private void Start()
    {
        if (_mode == TriggerMode.OnSceneLoad) TriggerCutscene();
    }

    private void TriggerCutscene()
    {
        if (_cutscene == null) return;

        // 已播放过且设置为仅播一次 → 跳过
        if (_cutscene.playOnlyOnce &&
            _worldState != null && _worldState.HasFlag($"cutscene_played_{_cutscene.cutsceneId}"))
            return;

        _cutsceneManager.PlayCutscene(_cutscene);
        _worldState?.SetFlag($"cutscene_played_{_cutscene.cutsceneId}");

        // Zone 触发后禁用自身,防止重复触发
        if (_mode == TriggerMode.OnEnter) enabled = false;
    }
}

资产路径Assets/ScriptableObjects/Cutscene/
命名规范CS_{SceneId}_{ContextId}.asset


11.6 SignalEmitterClip — Timeline 零耦合事件桥接

Design 来源18_CutsceneSystem §SignalEmitterClip

Timeline 过场通过此自定义 PlayableAsset 发布 SO 事件频道Timeline 动画与游戏逻辑不直接引用,保持零耦合原则。

// 路径: Assets/Scripts/Cutscene/SignalEmitterClip.cs
// 在 Timeline 轨道上放置此 ClipClip 播放时向目标事件频道 Raise 一次事件
[CreateAssetMenu(menuName = "BaseGames/Cutscene/SignalEmitterClip")]
public class SignalEmitterClip : PlayableAsset, ITimelineClipAsset
{
    [SerializeField] private EventChannelBaseSO _targetChannel;  // 目标事件频道 SO

    // ITimelineClipAsset
    public ClipCaps clipCaps => ClipCaps.None;

    public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
        => ScriptPlayable<SignalEmitterBehaviour>.Create(graph,
            new SignalEmitterBehaviour { Clip = this });
}

// 路径: Assets/Scripts/Cutscene/SignalEmitterBehaviour.cs
public class SignalEmitterBehaviour : PlayableBehaviour
{
    public SignalEmitterClip Clip;
    private bool _fired;

    public override void OnBehaviourPlay(Playable playable, FrameData info)
    {
        _fired = false;   // 重置,支持 Timeline 循环/重播
    }

    public override void ProcessFrame(Playable playable, FrameData info, object playerData)
    {
        if (!_fired && Clip._targetChannel != null)
        {
            Clip._targetChannel.RaiseEvent();
            _fired = true;
        }
    }
}

使用场景示例

  • 过场第 3 秒播放角色出现 → 触发 EVT_BossCutscenePhase2 频道 → BossOrchestrator 切换阶段
  • 过场结束前 0.5 秒 → 触发 EVT_CutscenePreEnd → HUD 开始淡入

12. 叙事事件频道清单

资产名 类型 Raise 方 Subscribe 方
EVT_DialogueStarted VoidEventChannelSO DialogueManager InputReaderSO(切 UI 输入)、PlayerController(锁定输入)
EVT_DialogueEnded VoidEventChannelSO DialogueManager InputReaderSO(切回 Gameplay
EVT_CutsceneStarted VoidEventChannelSO CutsceneManager HUDController(隐藏 HUDInputReaderSO
EVT_CutsceneEnded VoidEventChannelSO CutsceneManager HUDController(恢复 HUD

13. EventChainEditorWindow — 事件链可视化编辑器

路径Assets/Editor/Narrative/EventChainEditorWindow.csEditor-only
痛点EventChainSO 包含多个 ChainCondition[] + ChainAction[],在标准 Inspector 以 [SerializeReference] 列表展示10+ 条件/动作时极其冗长;链间依赖关系(ChainCompletedCondition.chainId)无可视化。本窗口提供节点列表式编辑视图,同时显示运行时执行状态。

13.1 功能规格

功能 说明
链总览面板 左侧显示所有 EventChainSO 资产列表,按区域/命名空间分组(chainId 前缀)
条件/动作表格 右侧展示选中链的条件(绿/红标记 IsMet())和动作(顺序编号)
运行时状态着色 Play Mode 下已完成链=绿、进行中=橙、未激活=白
依赖关系箭头 ChainCompletedCondition 在两条链之间绘制依赖箭头(链间先决条件可视化)
快速定位 双击链名 → EditorGUIUtility.PingObject() 高亮 Project 中对应资产
链执行日志 底部 Log 面板显示最近 20 条链执行记录(时间戳 + chainId + 成功/失败)

13.2 实现规范

// 路径: Assets/Editor/Narrative/EventChainEditorWindow.cs
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
using System.Collections.Generic;
using System.Linq;

namespace BaseGames.Editor.Narrative
{
    public class EventChainEditorWindow : EditorWindow
    {
        [MenuItem("BaseGames/Tools/Event Chain Viewer")]
        public static void Open() => GetWindow<EventChainEditorWindow>("事件链查看器");

        // ── 状态 ──────────────────────────────────────────────────────────
        private List<EventChainSO>  _allChains;
        private EventChainSO        _selected;
        private Vector2             _leftScroll;
        private Vector2             _rightScroll;
        private string              _filterText   = "";
        private static readonly List<string> _executionLog = new();  // 最多 20 条

        // 颜色
        private static readonly Color ColCompleted  = new(0.2f, 0.8f, 0.2f, 0.3f);
        private static readonly Color ColPending    = new(0.8f, 0.7f, 0.1f, 0.3f);
        private static readonly Color ColCondMet    = new(0.2f, 0.9f, 0.2f, 1f);
        private static readonly Color ColCondFail   = new(0.9f, 0.3f, 0.3f, 1f);

        private void OnEnable()  => RefreshChainList();
        private void OnFocus()   => RefreshChainList();

        private void RefreshChainList()
        {
            var guids  = AssetDatabase.FindAssets("t:EventChainSO");
            _allChains = guids
                .Select(g => AssetDatabase.LoadAssetAtPath<EventChainSO>(
                    AssetDatabase.GUIDToAssetPath(g)))
                .Where(c => c != null)
                .OrderBy(c => c.chainId)
                .ToList();
        }

        private void OnGUI()
        {
            DrawToolbar();
            EditorGUILayout.BeginHorizontal();
            DrawLeftPanel();   // 链列表
            DrawRightPanel();  // 条件/动作详情
            EditorGUILayout.EndHorizontal();
            DrawLogPanel();
        }

        // ── 工具栏 ────────────────────────────────────────────────────────
        private void DrawToolbar()
        {
            EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
            GUILayout.Label("过滤:", GUILayout.Width(36));
            _filterText = EditorGUILayout.TextField(_filterText, GUILayout.Width(180));
            if (GUILayout.Button("刷新", EditorStyles.toolbarButton, GUILayout.Width(44)))
                RefreshChainList();
            GUILayout.FlexibleSpace();
            if (Application.isPlaying)
                GUILayout.Label("● 运行中", new GUIStyle(EditorStyles.toolbarButton)
                    { normal = { textColor = Color.green } });
            EditorGUILayout.EndHorizontal();
        }

        // ── 左侧:链列表 ──────────────────────────────────────────────────
        private void DrawLeftPanel()
        {
            EditorGUILayout.BeginVertical(GUILayout.Width(220), GUILayout.ExpandHeight(true));
            EditorGUILayout.LabelField("事件链列表", EditorStyles.boldLabel);
            _leftScroll = EditorGUILayout.BeginScrollView(_leftScroll);

            // 按 NamespacechainId 首段)分组
            var groups = _allChains
                .Where(c => string.IsNullOrEmpty(_filterText) ||
                            c.chainId.Contains(_filterText, System.StringComparison.OrdinalIgnoreCase))
                .GroupBy(c => c.chainId.Contains('_') ? c.chainId[..c.chainId.IndexOf('_')] : "Global");

            foreach (var group in groups)
            {
                EditorGUILayout.LabelField(group.Key, EditorStyles.miniBoldLabel);
                foreach (var chain in group)
                {
                    bool isDone = Application.isPlaying &&
                        EventChainManager.Instance != null &&
                        EventChainManager.Instance.IsCompleted(chain.chainId);

                    var oldColor = GUI.backgroundColor;
                    GUI.backgroundColor = isDone ? ColCompleted : ColPending;

                    bool selected = GUILayout.Toggle(_selected == chain,
                        $"  {chain.chainId[(chain.chainId.IndexOf('_') + 1)..]}",
                        "Button");
                    GUI.backgroundColor = oldColor;

                    if (selected)
                    {
                        _selected = chain;
                        EditorGUIUtility.PingObject(chain);
                    }
                }
            }

            EditorGUILayout.EndScrollView();
            EditorGUILayout.EndVertical();
        }

        // ── 右侧:条件 + 动作 ────────────────────────────────────────────
        private void DrawRightPanel()
        {
            EditorGUILayout.BeginVertical(GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true));
            if (_selected == null)
            {
                EditorGUILayout.HelpBox("← 选择左侧的事件链查看详情", MessageType.Info);
                EditorGUILayout.EndVertical();
                return;
            }

            EditorGUILayout.LabelField(_selected.chainId, EditorStyles.boldLabel);
            EditorGUILayout.LabelField(
                $"重复触发:{(_selected.repeatable ? "" : "")}    动作间隔:{_selected.actionDelay:F1}s",
                EditorStyles.miniLabel);
            EditorGUILayout.Space(6);

            _rightScroll = EditorGUILayout.BeginScrollView(_rightScroll);

            // 条件列表
            EditorGUILayout.LabelField("触发条件", EditorStyles.boldLabel);
            if (_selected.conditions != null)
            {
                for (int i = 0; i < _selected.conditions.Length; i++)
                {
                    var cond = _selected.conditions[i];
                    bool met = Application.isPlaying && cond.IsMet();

                    EditorGUILayout.BeginHorizontal();
                    // 运行时绿/红指示灯
                    if (Application.isPlaying)
                    {
                        var dot = new GUIStyle(EditorStyles.miniLabel)
                            { normal = { textColor = met ? ColCondMet : ColCondFail } };
                        GUILayout.Label(met ? "●" : "○", dot, GUILayout.Width(14));
                    }
                    EditorGUILayout.ObjectField($"  {i + 1}.", cond,
                        typeof(ChainCondition), false);
                    EditorGUILayout.EndHorizontal();
                }
            }

            EditorGUILayout.Space(8);

            // 动作列表
            EditorGUILayout.LabelField("执行动作(顺序)", EditorStyles.boldLabel);
            if (_selected.actions != null)
            {
                for (int i = 0; i < _selected.actions.Length; i++)
                {
                    EditorGUILayout.BeginHorizontal();
                    GUILayout.Label($"  {i + 1}.", GUILayout.Width(24));
                    EditorGUILayout.ObjectField(_selected.actions[i],
                        typeof(ChainAction), false);
                    EditorGUILayout.EndHorizontal();
                }
            }

            EditorGUILayout.EndScrollView();
            EditorGUILayout.EndVertical();
        }

        // ── 底部:执行日志 ────────────────────────────────────────────────
        private void DrawLogPanel()
        {
            EditorGUILayout.LabelField("执行日志(最近 20 条)", EditorStyles.boldLabel,
                GUILayout.Height(18));
            var logStyle = new GUIStyle(EditorStyles.miniLabel) { wordWrap = false };
            foreach (var line in _executionLog.TakeLast(20))
                EditorGUILayout.LabelField(line, logStyle);
        }

        // ── 运行时日志注入(由 EventChainManager 调用)────────────────────
        public static void LogExecution(string chainId, bool success)
        {
            _executionLog.Add(
                $"[{System.DateTime.Now:HH:mm:ss}] {(success ? "" : "")} {chainId}");
            if (_executionLog.Count > 100) _executionLog.RemoveAt(0);
        }
    }
}
#endif

EventChainManager 集成:在 ExecuteChain 协程末尾调用 EventChainEditorWindow.LogExecution(chain.chainId, true)(仅 #if UNITY_EDITOR)即可向窗口注入执行记录,无运行时开销。