47 KiB
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)
目录
- IInteractable 接口
- InteractionPromptController(交互提示 UI)
- DialogueLineSO 与 DialogueSequenceSO
- DialogueManager
- DialogueUI(对话框组件)
- InteractableNPC
- NarrativeNPC(条件对话 NPC)
- WorldStateRegistry
- EventChain(世界事件链)
- EventChainManager
- CutsceneManager(Timeline 封装)
- 叙事事件频道清单
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 的组件:SavePoint、InteractableNPC、ShopNPC、AbilityUnlock、Sign(告示牌)
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);
}
检测范围:独立的 CircleCollider2D(Trigger),半径约 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_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):
// 路径: 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_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
// 路径: 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<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 注入,用于记录/查询播放状态
// ── 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 §SignalEmitterClip
Timeline 过场通过此自定义 PlayableAsset 发布 SO 事件频道,Timeline 动画与游戏逻辑不直接引用,保持零耦合原则。
// 路径: 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<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 实现规范
// 路径: 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);
// 按 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)即可向窗口注入执行记录,无运行时开销。