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

1200 lines
47 KiB
Markdown
Raw 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.
# 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. [CutsceneManagerTimeline 封装)](#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<Gamepad>().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_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](./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()); // 启动对话
}
/// <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` 标志动态选择对话版本:
```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<string>`,支持 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<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世界事件链
```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_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
```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_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](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<string> 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<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
```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;
}
/// <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](../Design/18_CutsceneSystem.md) §SignalEmitterClip
Timeline 过场通过此自定义 `PlayableAsset` 发布 SO 事件频道Timeline 动画与游戏逻辑不直接引用,保持零耦合原则。
```csharp
// 路径: 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`(隐藏 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<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`)即可向窗口注入执行记录,无运行时开销。