Files
zeling_v2/Docs/Design/15_DialogueSystem.md
2026-05-08 11:04:00 +08:00

788 lines
25 KiB
Markdown
Raw Permalink 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.
# 15 · 对话系统与交互接口
> **命名空间** `BaseGames.Dialogue`
> **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md)
> **依赖** `BaseGames.Core.Events` · `BaseGames.UI`DialogueBox· `BaseGames.Input`Action Map 切换)
---
## 目录
1. [系统总览](#1-系统总览)
2. [IInteractable — 通用交互接口](#2-iinteractable--通用交互接口)
3. [交互提示 UIInteractionPrompt](#3-交互提示-uiinteractionprompt)
4. [数据结构DialogueLine / DialogueSequenceSO](#4-数据结构dialogueline--dialoguesequenceso)
5. [DialogueManager](#5-dialoguemanager)
6. [DialogueUI对话框组件](#6-dialogueui对话框组件)
7. [InteractableNPC](#7-interactablenpc)
8. [对话触发条件](#8-对话触发条件)
9. [输入 Action Map 切换](#9-输入-action-map-切换)
10. [与 ShopNPC 集成](#10-与-shopnpc-集成)
11. [事件频道](#11-事件频道)
12. [编辑器友好设计](#12-编辑器友好设计)
13. [NPC 好感度系统](#13-npc-好感度系统)
---
## 1. 系统总览
```
对话系统职责:
├─ IInteractable 接口 → 统一所有可交互物件NPC/告示牌/机关)的交互入口
├─ InteractionPrompt UI → 玩家靠近可交互物件时显示"按 [F] 交互"提示
├─ DialogueSequenceSO → 对话内容数据化,策划直接在 Inspector 中编辑台词
├─ DialogueManager → 管理对话播放状态,驱动 DialogueUI
├─ DialogueUI → 打字机效果文字框、说话人名称、继续提示
└─ 对话条件系统 → 根据游戏进程显示不同对话内容P1 分支)
```
---
## 2. IInteractable — 通用交互接口
统一所有可交互物件的接口,`PlayerInteractState` 只需知道此接口:
```csharp
namespace BaseGames.World
{
public interface IInteractable
{
// 是否当前可以被交互(如 ShopNPC 战斗中不可交互)
bool CanInteract { get; }
// 玩家按下交互键时调用
void Interact(Transform player);
// 玩家进入/离开交互范围时调用(用于驱动 InteractionPrompt 显隐)
void OnPlayerEnterRange(Transform player);
void OnPlayerExitRange();
}
}
```
### 已实现 IInteractable 的组件
| 组件 | 说明 |
|------|------|
| `SavePoint` | 存档(已存在,补充实现 IInteractable|
| `InteractableNPC` | 触发对话序列 |
| `ShopNPC` | 先触发对话,再打开商店 UI |
| `AbilityUnlock` | 拾取能力道具(已存在,补充实现 IInteractable|
| `DirectionalInteractable` | 机关/开关(见 08_WorldSystem.md §9.6|
| `Sign` | 告示牌显示单行静态文字IInteractable 最简实现)|
---
## 3. 交互提示 UIInteractionPrompt
当玩家进入 `IInteractable` 的交互范围Trigger Collider在物件上方显示交互提示图标
```
InteractionPrompt PrefabWorld Space Canvas
├── Icon (Sprite键盘 [F] / 手柄 [A] 图标,随当前输入设备切换)
└── Label (TextMeshPro"交互" 或留空)
```
**挂载方式**:每个 `IInteractable` GameObject 下都挂载一个 `InteractionPrompt` 子对象Prefab 实例),默认隐藏:
```csharp
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 检查 */;
_icon.sprite = isGamepad ? _gamepadIcon : _keyboardIcon;
}
public void Hide() => _promptRoot.SetActive(false);
}
```
**检测范围**:独立的 `CircleCollider2D`Trigger半径约 1.5 单位,检测 `Player` 层,比战斗 HurtBox 更大,保证靠近就显示提示。
---
## 4. 数据结构DialogueLine / DialogueSequenceSO
### DialogueLine单行对话数据
```csharp
[Serializable]
public class DialogueLine
{
public string speakerName; // 说话人名字(空字符串 = 旁白,不显示名称框)
public Sprite speakerPortrait; // P1说话人头像
[TextArea(3, 6)]
public string text; // 对话文本(支持 <color>、<b> 等 TextMeshPro 富文本标签)
public float displaySpeed = 0.03f; // 打字机速度(秒/字)
public AudioEventSO voiceSFX; // P1语音音效如每次出字播放的 "咚咚" 音)
}
```
### DialogueSequenceSO
```csharp
[CreateAssetMenu(menuName = "Dialogue/DialogueSequence")]
public class DialogueSequenceSO : ScriptableObject
{
public DialogueLine[] lines;
[Header("触发条件P1 分支对话)")]
public DialogueCondition[] conditions; // 满足条件时才使用此序列(否则跳到下一个)
}
```
资产路径:`Assets/ScriptableObjects/Dialogue/DS_{NpcName}_{Context}.asset`
---
## 5. DialogueManager
`DialogueManager` 常驻 Persistent 场景,管理对话播放状态:
```csharp
namespace BaseGames.Dialogue
{
public class DialogueManager : MonoBehaviour
{
public static DialogueManager Instance { get; private set; }
[SerializeField] DialogueUI _dialogueUI;
[SerializeField] VoidEventChannelSO _onDialogueStarted;
[SerializeField] VoidEventChannelSO _onDialogueEnded;
[SerializeField] InputReaderSO _inputReader;
bool _isPlaying;
DialogueSequenceSO _currentSequence;
int _currentLineIndex;
public bool IsPlaying => _isPlaying;
public void StartDialogue(DialogueSequenceSO sequence)
{
if (_isPlaying) return;
_isPlaying = true;
_currentSequence = sequence;
_currentLineIndex = 0;
_inputReader.EnableGameplayInput(false);
_inputReader.EnableUIInput(true); // 切换到 UI Action Map
_inputReader.ConfirmEvent += OnConfirm; // [F]/[A] 确认/跳过
_onDialogueStarted.Raise();
ShowCurrentLine();
}
void ShowCurrentLine()
{
if (_currentLineIndex >= _currentSequence.lines.Length)
{
EndDialogue();
return;
}
_dialogueUI.ShowLine(_currentSequence.lines[_currentLineIndex]);
}
// 玩家按确认键:若打字机未完成则立即显示全文;若已完成则翻页
void OnConfirm()
{
if (_dialogueUI.IsTyping)
_dialogueUI.SkipTyping();
else
{
_currentLineIndex++;
ShowCurrentLine();
}
}
void EndDialogue()
{
_isPlaying = false;
_inputReader.ConfirmEvent -= OnConfirm;
_inputReader.EnableUIInput(false);
_inputReader.EnableGameplayInput(true);
_dialogueUI.Hide();
_onDialogueEnded.Raise();
}
}
}
```
---
## 6. DialogueUI对话框组件
挂载在 `Canvas_Overlay` 下的 `DialogueBox` 子对象(见 [10_UISystem.md §2](./10_UISystem.md)
```csharp
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;
public bool IsTyping { get; private set; }
public void ShowLine(DialogueLine line)
{
_rootPanel.SetActive(true);
_continuePrompt.SetActive(false);
bool hasSpeaker = !string.IsNullOrEmpty(line.speakerName);
_speakerNamePanel.SetActive(hasSpeaker);
if (hasSpeaker) _speakerNameText.text = line.speakerName;
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.text.Length);
_dialogueText.text = "";
foreach (char c in line.text)
{
sb.Append(c);
_dialogueText.SetText(sb); // TMP SetText(StringBuilder) 零分配
yield return new WaitForSecondsRealtime(line.displaySpeed);
}
IsTyping = false;
_continuePrompt.SetActive(true);
}
public void SkipTyping()
{
if (_typingCoroutine != null) StopCoroutine(_typingCoroutine);
_dialogueText.text = _currentLine?.text ?? "";
IsTyping = false;
_continuePrompt.SetActive(true);
}
public void Hide() => _rootPanel.SetActive(false);
DialogueLine _currentLine; // ShowLine 时缓存以供 SkipTyping 使用
}
```
### 对话框布局
```
DialogueBox固定在屏幕底部Canvas_Overlay
├── DialogueBG (半透明黑色面板,圆角,内边距 20px)
├── SpeakerNamePanel (左上角,有说话人时显示)
│ └── SpeakerNameText (TextMeshPro像素字体白色)
├── DialogueText (TextMeshPro像素字体白色最大 3 行)
├── PortraitFrame (P1右侧人物头像框)
└── ContinuePrompt (右下角 "▼" 动画图标,打字完成后出现)
```
---
## 7. InteractableNPC
NPC 交互的通用实现,继承 `IInteractable`
```csharp
public class InteractableNPC : MonoBehaviour, IInteractable
{
[SerializeField] DialogueSequenceSO[] _dialogueSequences; // 多段对话按顺序播放
[SerializeField] InteractionPromptController _prompt;
int _currentSequenceIndex = 0;
bool _canInteract = true;
public bool CanInteract => _canInteract;
public void OnPlayerEnterRange(Transform player) => _prompt.Show();
public void OnPlayerExitRange() => _prompt.Hide();
public void Interact(Transform player)
{
if (!_canInteract || DialogueManager.Instance.IsPlaying) return;
// 选取当前有效的对话序列P1条件检查
var sequence = GetCurrentSequence();
if (sequence == null) return;
DialogueManager.Instance.StartDialogue(sequence);
// 订阅对话结束事件,推进对话索引
_onDialogueEnded.OnEventRaised += OnDialogueEnded;
}
void OnDialogueEnded()
{
_onDialogueEnded.OnEventRaised -= OnDialogueEnded;
// 推进到下一段对话(如已是最后一段则保持在最后一段,重复播放)
if (_currentSequenceIndex < _dialogueSequences.Length - 1)
_currentSequenceIndex++;
}
DialogueSequenceSO GetCurrentSequence()
=> _currentSequenceIndex < _dialogueSequences.Length
? _dialogueSequences[_currentSequenceIndex]
: null;
}
```
### NPC 面朝玩家
对话开始时NPC 自动翻转 Sprite 朝向玩家(类 Hollow Knight 行为):
```csharp
// DialogueManager.StartDialogue() 中:
// 若 sequence 的 owner 是 InteractableNPC则 npc.FaceToward(player.position)
```
---
## 8. 对话触发条件
P1 扩展:根据游戏进程显示不同对话内容(如击败 Boss 后 NPC 台词变化):
```csharp
[Serializable]
public class DialogueCondition
{
public ConditionType type; // BossDefeated / AbilityUnlocked / ItemOwned / FirstVisit
public string requiredId; // Boss ID / Ability ID / Item ID
public bool negate; // true = 条件取反(如"尚未击败某Boss"
}
// InteractableNPC.GetCurrentSequence() P1 实现:
DialogueSequenceSO GetCurrentSequence()
{
foreach (var seq in _dialogueSequences)
{
if (AllConditionsMet(seq.conditions))
return seq;
}
return _dialogueSequences[^1]; // 最后一段作为兜底
}
```
---
## 9. 输入 Action Map 切换
对话进行时禁用 Gameplay 输入,仅响应 UI 输入(确认/跳过):
| 状态 | Gameplay Map | UI Map | 说明 |
|------|:---:|:---:|------|
| 正常游玩 | ✅ 启用 | ❌ 禁用 | 移动/攻击/弹反 |
| 对话中 | ❌ 禁用 | ✅ 启用 | 仅 [F]/[A] 翻页 |
| 暂停菜单 | ❌ 禁用 | ✅ 启用 | 菜单导航 |
```csharp
// InputReaderSO 扩展方法:
public void EnableGameplayInput(bool enable)
=> _playerInput.SwitchCurrentActionMap(enable ? "Gameplay" : "UI");
// 对话期间绑定的 Action
// UI Map 的 "Submit"(对应键盘 Enter/F、手柄 A→ DialogueManager.OnConfirm()
// UI Map 的 "Cancel"(对应键盘 Esc、手柄 B→ P1跳过整段对话
```
---
## 10. 与 ShopNPC 集成
`ShopNPC` 继承 `InteractableNPC`,在对话结束后打开商店 UI
```csharp
public class ShopNPC : InteractableNPC
{
[SerializeField] ShopInventorySO _inventory;
[SerializeField] VoidEventChannelSO _onDialogueEnded;
bool _shopOpenedThisVisit = false;
protected override void OnDialogueEnded()
{
base.OnDialogueEnded();
// 对话结束(仅首次对话后才打开商店,之后再次交互直接开店)
if (!_shopOpenedThisVisit || _currentSequenceIndex > 0)
{
_shopOpenedThisVisit = true;
OpenShop();
}
}
void OpenShop()
{
// 零耦合:通过事件频道请求打开商店 UI不直接调用 UIManager.Instance
_onOpenShopRequested.Raise(_inventory); // ShopInventoryEventChannelSO
}
}
```
> `ShopInventoryEventChannelSO``ShopInventorySO` 类型事件频道)发布后,`UIManager` 组件订阅该频道并调用其内部的 `OpenShopPanel(ShopInventorySO)`。这样 ShopNPC 不持有对 UIManager 的任何引用。
```
---
## 11. 事件频道
新增频道(`Assets/ScriptableObjects/Events/Dialogue/`
| 资产名 | 类型 | 用途 |
|--------|------|------|
| `OnDialogueStarted.asset` | `VoidEventChannelSO` | 对话开始GameManager 可锁定相关状态 |
| `OnDialogueEnded.asset` | `VoidEventChannelSO` | 对话结束恢复输入ShopNPC 监听 |
---
## 12. 编辑器友好设计
- `DialogueSequenceSO` Custom Inspector逐行预览对话内容带 NPC 名字颜色标识,支持换行显示完整台词
- `InteractableNPC` 交互范围 Gizmo在 Scene View 显示交互半径(蓝色虚线圆圈)
- `DialogueManager` Inspector实时显示"是否正在播放"状态 + 当前对话序列名 + 当前行索引
- `InteractionPrompt` 在编辑器中始终显示(`[ExecuteInEditMode]`),方便调整位置
- EditorWindow `DialogueValidator`:扫描全部 `DialogueSequenceSO`,检测空 text 字段、缺失 speakerName 等数据问题UI Toolkit `ListView` + 可点击 `Label``CreateGUI()` 实现)
---
## 13. NPC 好感度系统
并非所有 NPC 都需要好感度。本系统适用于对玩家行为有明显反应的重要 NPC商人、第一个遇到的 NPC、隐藏的 NPC等
### 13.1 NPCRelationshipSO
```csharp
[CreateAssetMenu(menuName = "Dialogue/NPCRelationship")]
public class NPCRelationshipSO : ScriptableObject
{
[Header("基础信息")]
public string npcId; // 必须与 InteractableNPC._npcId 一致
public string displayName;
[Header("好感度阈値")]
[Tooltip("对话变化阈値,升序配置")]
public AffinityThreshold[] thresholds;
[Header("初始值")]
[Range(0, 100)]
public int initialAffinity = 50;
}
[Serializable]
public struct AffinityThreshold
{
[Range(0, 100)] public int minAffinity;
public string stateLabel; // 如 "Cold" / "Neutral" / "Friendly" / "Trusted"
public DialogueSequenceSO greetingDialogue; // 该阶段打招对话
public Sprite npcPortrait; // 对话框头像(可能随状态变化表情)
}
```
### 13.2 RelationshipManager
```csharp
/// <summary>
/// 单例,负责读写全局 NPC 好感度数据。
/// </summary>
public class RelationshipManager : MonoBehaviour
{
public static RelationshipManager Instance { get; private set; }
// Key = npcId
readonly Dictionary<string, int> _affinities = new();
void Awake()
{
Instance = this;
LoadFromSave();
}
public int GetAffinity(string npcId)
=> _affinities.TryGetValue(npcId, out int v) ? v : 50;
public void ChangeAffinity(string npcId, int delta)
{
int current = GetAffinity(npcId);
_affinities[npcId] = Mathf.Clamp(current + delta, 0, 100);
SaveToCurrent();
}
public string GetStateLabel(NPCRelationshipSO rel)
{
int affinity = GetAffinity(rel.npcId);
AffinityThreshold best = rel.thresholds[0];
foreach (var t in rel.thresholds)
if (affinity >= t.minAffinity) best = t;
return best.stateLabel;
}
public DialogueSequenceSO GetGreetingDialogue(NPCRelationshipSO rel)
{
int affinity = GetAffinity(rel.npcId);
DialogueSequenceSO best = rel.thresholds[0].greetingDialogue;
foreach (var t in rel.thresholds)
if (affinity >= t.minAffinity && t.greetingDialogue != null)
best = t.greetingDialogue;
return best;
}
void LoadFromSave()
{
var saved = SaveManager.Instance.GetRelationships();
foreach (var kv in saved)
_affinities[kv.Key] = kv.Value;
}
void SaveToCurrent()
{
SaveManager.Instance.SetRelationships(_affinities);
}
}
```
### 13.3 InteractableNPC 与好感度集成
`InteractableNPC` 增加可选的 `NPCRelationshipSO` 字段:
```csharp
public class InteractableNPC : MonoBehaviour, IInteractable
{
// 原有字段保持不变 ...
[Header("好感度(可选)")]
[SerializeField] NPCRelationshipSO _relationship; // 若为 null 则不使用好感度系统
protected virtual DialogueSequenceSO GetCurrentDialogue()
{
// 如果配置了好感度 SO优先使用好感度驱动的问候对话
if (_relationship != null)
{
var greeting = RelationshipManager.Instance.GetGreetingDialogue(_relationship);
if (greeting != null) return greeting;
}
// 回退到原有进程驱动的对话选择逻辑
foreach (var entry in _dialogueEntries)
{
if (entry.condition == null || entry.condition.IsMet())
return entry.dialogue;
}
return _defaultDialogue;
}
}
```
### 13.4 好感度变化方式
| 触发方式 | 示例 | 实现位置 |
|-----------|------|----------|
| **对话选择** | 玩家选择友善回应 | `DialogueLine.affinityDelta` 字段,`DialogueManager` 播放后调用 `ChangeAffinity` |
| **被保护 NPC** | 击杀刺客后 NPC 生气阶段提升 | `EventChainSO` Action: `ChangeAffinityAction` |
| **交物品** | 将 NPC 要的物品交给他 | `ShopNPC.GiftItem()` 调用 `ChangeAffinity` |
| **时间衰减** | 长时间不与 NPC 互动 | 可选功能,通过定时器缓慢减少(默认不启用)|
### 13.5 合法 DialogueLine 扩展
```csharp
// DialogueLine 新增字段
[Serializable]
public class DialogueLine
{
// 原有字段...
public string speakerName;
[TextArea(2, 6)] public string text;
public AudioClip voiceClip;
public Sprite speakerPortrait;
[Header("好感度影响(可选)")]
[Tooltip("此行对话结束后对说话 NPC 的好感度变化(正=提升,负=降低)")]
public int affinityDelta; // 0 表示不变化
}
```
### 13.6 SaveData 扩展
```json
"relationships": {
"npc_merchant": 72,
"npc_elder": 45,
"npc_hidden_master": 10
}
```
```csharp
// SaveManager 扩展
public Dictionary<string, int> GetRelationships()
=> _saveData.relationships ?? new Dictionary<string, int>();
public void SetRelationships(Dictionary<string, int> data)
{
_saveData.relationships = data;
WriteDirty();
}
```
### 13.7 好感度条件 — AffinityThresholdCondition
可用于 `NavHintCondition``EventChainCondition``DialogueTriggerCondition`
```csharp
[CreateAssetMenu(menuName = "Dialogue/Condition/AffinityThreshold")]
public class AffinityThresholdCondition : DialogueTriggerCondition
{
public string npcId;
[Range(0, 100)] public int minAffinity;
[Range(0, 100)] public int maxAffinity = 100;
public override bool IsMet()
{
int v = RelationshipManager.Instance.GetAffinity(npcId);
return v >= minAffinity && v <= maxAffinity;
}
}
```
**典型用例**
- `minAffinity=80` → NPC 信任玩家后才告诉他隐藏路点
- `minAffinity=0, maxAffinity=20` → 好感极低时 NPC 拒绝交易
---
## 14. NarrativeNPC 世界状态响应WorldState Integration
> 本节为 [50_NarrativeDesignSystem.md](./50_NarrativeDesignSystem.md) 的对话层集成说明。
> `NarrativeNPC` 是对 `InteractableNPC` 的扩展,支持基于 `WorldStateRegistry` 全局标志的对话版本切换。
### 14.1 NarrativeNPC 与 InteractableNPC 的关系
```
InteractableNPC (基类)
└── NarrativeNPC (扩展)
├── 继承好感度系统§13
├── 继承基础对话触发§8
└── 新增WorldStateRegistry 标志驱动的对话版本选择
```
### 14.2 DialogueVersion世界状态对话版本
```csharp
[Serializable]
public struct DialogueVersion
{
[Tooltip("版本标签(调试用,如 'pre_cave_boss' / 'post_true_ending'")]
public string label;
[Tooltip("需要 WorldStateRegistry 中存在的所有标志")]
public string[] requiredFlags;
[Tooltip("若 WorldStateRegistry 中存在任一标志,此版本被排除")]
public string[] blockedByFlags;
[Tooltip("优先级,数值越高越优先")]
public int priority;
public DialogueSequenceSO dialogue;
}
```
### 14.3 NarrativeNPC 对话选择逻辑
```csharp
public class NarrativeNPC : InteractableNPC
{
[Header("世界状态对话版本")]
[SerializeField] DialogueVersion[] _worldStateDialogues;
protected override DialogueSequenceSO GetCurrentDialogue()
{
// 1. 优先通过世界状态选择对话版本
var registry = WorldStateRegistry.Instance;
DialogueVersion? best = null;
foreach (var version in _worldStateDialogues)
{
// 检查是否所有 requiredFlags 存在
bool allRequired = Array.TrueForAll(
version.requiredFlags, f => registry.HasFlag(f));
// 检查是否有 blockedByFlags 阻挡
bool anyBlocked = Array.Exists(
version.blockedByFlags, f => registry.HasFlag(f));
if (allRequired && !anyBlocked)
{
if (!best.HasValue || version.priority > best.Value.priority)
best = version;
}
}
if (best.HasValue) return best.Value.dialogue;
// 2. 回退到好感度驱动对话§13
// 3. 最终回退到基类默认对话§7
return base.GetCurrentDialogue();
}
}
```
### 14.4 世界状态标志命名规范
`50_NarrativeDesignSystem §2.1` 保持一致:
| 前缀 | 含义 | 示例 |
|------|------|------|
| `boss_` | Boss 击败 | `boss_forest_defeated`, `boss_cave_defeated` |
| `quest_` | 任务状态 | `quest_elder_request_complete` |
| `npc_` | NPC 特定事件 | `npc_merchant_migrated_to_cave` |
| `item_` | 关键道具获取 | `item_echo_fragment_5_collected` |
| `area_` | 区域发现/解锁 | `area_ruins_secret_found` |
| `story_` | 剧情节点达成 | `story_true_ending_conditions_met` |
### 14.5 典型配置示例ElderSage NPC
```
ElderSage NPC DialogueVersions:
[0] label: "初次见面"
requiredFlags: []
blockedByFlags: ["boss_forest_defeated"]
priority: 0
→ dialogue: elder_intro.asset
[1] label: "森林Boss击败后"
requiredFlags: ["boss_forest_defeated"]
blockedByFlags: ["boss_cave_defeated"]
priority: 10
→ dialogue: elder_after_forest.asset
[2] label: "Cave Boss击败后"
requiredFlags: ["boss_forest_defeated", "boss_cave_defeated"]
blockedByFlags: ["story_true_ending_conditions_met"]
priority: 20
→ dialogue: elder_after_cave.asset
[3] label: "True Ending条件满足后"
requiredFlags: ["story_true_ending_conditions_met"]
blockedByFlags: []
priority: 100
→ dialogue: elder_true_ending_hint.asset
```
### 14.6 事件链集成NarrativeNodeSO 效果触发)
当对话的最后一行触发 `NarrativeNodeSO` 效果时(如 NPC 迁移、世界标志设置),通过 `DialogueLine``onComplete` UnityEvent 调用 `WorldStateRegistry.SetFlag()`
```csharp
// 在 DialogueLine 中(或 EventChainSO Action 中)
WorldStateRegistry.Instance.SetFlag("npc_elder_migrated_to_ruins");
NPCMigrationService.Instance.TriggerMigration("npc_elder", "ruins_temple");
```
这确保对话与世界状态的变化原子性发生,不存在对话播完但状态未更新的窗口。