1200 lines
47 KiB
Markdown
1200 lines
47 KiB
Markdown
# 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<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_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()); // 启动对话
|
||
}
|
||
|
||
/// <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_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<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 注入,用于记录/查询播放状态
|
||
|
||
// ── 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<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);
|
||
|
||
// 按 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`)即可向窗口注入执行记录,无运行时开销。
|