# 14 · 叙事模块 > **命名空间** `BaseGames.Dialogue`、`BaseGames.Cutscene`、`BaseGames.EventChain`、`BaseGames.World`(IInteractable、WorldStateRegistry) > **程序集** `BaseGames.Dialogue`、`BaseGames.Cutscene`、`BaseGames.EventChain` > **路径** `Assets/Scripts/Dialogue/`、`Assets/Scripts/Cutscene/`、`Assets/Scripts/EventChain/` > **依赖** `BaseGames.Core.Events`、`BaseGames.Input`(Action Map 切换)、`BaseGames.World`(SaveManager) --- ## 目录 1. [IInteractable 接口](#1-iinteractable-接口) 2. [InteractionPromptController(交互提示 UI)](#2-interactionpromptcontroller) 3. [DialogueLineSO 与 DialogueSequenceSO](#3-dialoguelineso-与-dialoguesequenceso) 4. [DialogueManager](#4-dialoguemanager) 5. [DialogueUI(对话框组件)](#5-dialogueui) 6. [InteractableNPC](#6-interactablenpc) 7. [NarrativeNPC(条件对话 NPC)](#7-narrativenpcc) 8. [WorldStateRegistry](#8-worldstateregistry) 9. [EventChain(世界事件链)](#9-eventchain) 10. [EventChainManager](#10-eventchainmanager) 11. [CutsceneManager(Timeline 封装)](#11-cutscenemanager) 12. [叙事事件频道清单](#12-叙事事件频道清单) --- ## 1. IInteractable 接口 ```csharp // 路径: 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 的组件**:`SavePoint`、`InteractableNPC`、`ShopNPC`、`AbilityUnlock`、`Sign`(告示牌) --- ## 2. InteractionPromptController(交互提示 UI) 当玩家进入 `IInteractable` 的交互范围时,在物件上方显示交互提示图标: ```csharp // 路径: 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().Any(g => g.enabled); _icon.sprite = isGamepad ? _gamepadIcon : _keyboardIcon; } public void Hide() => _promptRoot.SetActive(false); } ``` **检测范围**:独立的 `CircleCollider2D`(Trigger),半径约 1.5 单位,检测 `Player` 层,由 `InteractableDetector` 统一驱动 Show/Hide。 --- ## 3. DialogueLineSO 与 DialogueSequenceSO ```csharp // 路径: 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 ```csharp // 路径: 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_NpcDialogueCompleted(npcId,QuestManager 订阅) [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](./10_UISystem.md)): ```csharp // 路径: 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 ```csharp // 路径: 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()); // 启动对话 } /// 子类覆盖此方法以在对话前注入额外逻辑(如接受/完成任务)。 protected virtual void Interact_Internal(Transform player) { } /// 子类覆盖此方法以根据游戏状态返回不同的对话 SO(见 NarrativeNPC)。 protected virtual DialogueSequenceSO GetCurrentDialogue() => _defaultDialogue; public void OnPlayerEnterRange(Transform player) { } // 交互提示 UI 由 InteractableDetector 统一发布 public void OnPlayerExitRange() { } // 交互提示 UI 由 InteractableDetector 统一发布 } ``` --- ## 7. NarrativeNPC(条件对话 NPC) 扩展 `InteractableNPC`,支持根据 `WorldStateRegistry` 标志动态选择对话版本: ```csharp // 路径: 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`,支持 DLC 子集按命名空间卸载,防止键名冲突: ```csharp // 路径: 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> _nsFlags = new(); // ── 写入 ────────────────────────────────────────────────────────── /// /// 设置标志。
/// 格式:Namespace_Object_State(如 Boss_SpiderGuard_Defeated)。 /// Namespace 取第一段;若无下划线则归入 "Global"。 ///
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(); if (value) set.Add(local); else set.Remove(local); } // ── 读取 ────────────────────────────────────────────────────────── /// 查询标志(任意命名空间或全限定 key 均可查)。 public bool HasFlag(string flagKey) { var (ns, local) = Split(flagKey); return _nsFlags.TryGetValue(ns, out var set) && set.Contains(local); } // ── 命名空间级操作(DLC 卸载用)──────────────────────────────── /// 卸载指定命名空间的所有标志(DLC 退出时调用)。 public void ClearNamespace(string ns) => _nsFlags.Remove(ns); public IEnumerable GetFlagsInNamespace(string ns) => _nsFlags.TryGetValue(ns, out var s) ? (IEnumerable)s : System.Array.Empty(); // ── 存档 I/O ────────────────────────────────────────────────────── /// 从存档恢复(传入全限定 key 列表)。 public void LoadFromSave(IEnumerable flags) { _nsFlags.Clear(); foreach (var f in flags) SetFlag(f); } /// 序列化为全限定 key 列表(存档用)。 public IEnumerable 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 | 示例 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(世界事件链) ```csharp // 路径: 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_AbilityUnlocked(StringEventChannelSO)传来的 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 ```csharp // 路径: 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_AbilityUnlocked(StringEventChannelSO) [SerializeField] StringEventChannelSO _onRoomEntered; // EVT_SceneLoaded [SerializeField] StringEventChannelSO _onDialogueCompleted; // EVT_NpcDialogueCompleted // 中继 C# 事件,供 ChainCondition.Register() 订阅 public event Action OnBossDefeated; public event Action OnCollectiblePickedUp; public event Action OnAbilityUnlocked; public event Action OnRoomEntered; public event Action OnDialogueCompleted; public event Action OnChainCompleted; // 链完成时广播 chainId(供 ChainCompletedCondition) readonly HashSet _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](12_SaveModule.md)): ```json "eventChains": { "completedChains": ["Chain_BossForest_Defeated", "Chain_Forest_DoorOpened"], "flags": { "ForestBossDefeated": true, "AbyssUnlocked": false } } ``` ```csharp // SaveManager 扩展方法(参见 12_SaveModule.md §SaveData 结构) public IEnumerable GetCompletedChains(); public void SetChainCompleted(string chainId); public bool GetFlag(string flagId); public void SetFlag(string flagId, bool value); ``` --- ## 11. CutsceneManager ```csharp // 路径: 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(); 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 ```csharp // 路径: 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; } /// 将一条 Timeline Track(通过名称索引)绑定到运行时场景对象。 [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 注入,用于记录/查询播放状态 // ── IInteractable(TriggerMode.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](../Design/18_CutsceneSystem.md) §SignalEmitterClip Timeline 过场通过此自定义 `PlayableAsset` 发布 SO 事件频道,Timeline 动画与游戏逻辑不直接引用,保持零耦合原则。 ```csharp // 路径: Assets/Scripts/Cutscene/SignalEmitterClip.cs // 在 Timeline 轨道上放置此 Clip,Clip 播放时向目标事件频道 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.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`(隐藏 HUD)、`InputReaderSO` | | `EVT_CutsceneEnded` | `VoidEventChannelSO` | `CutsceneManager` | `HUDController`(恢复 HUD)| --- ## 13. EventChainEditorWindow — 事件链可视化编辑器 > **路径**:`Assets/Editor/Narrative/EventChainEditorWindow.cs`(Editor-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 实现规范 ```csharp // 路径: 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("事件链查看器"); // ── 状态 ────────────────────────────────────────────────────────── private List _allChains; private EventChainSO _selected; private Vector2 _leftScroll; private Vector2 _rightScroll; private string _filterText = ""; private static readonly List _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( 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); // 按 Namespace(chainId 首段)分组 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`)即可向窗口注入执行记录,无运行时开销。