chore: initial commit

This commit is contained in:
2026-05-08 11:04:00 +08:00
commit f55d2a57c3
6278 changed files with 866081 additions and 0 deletions

View File

@@ -0,0 +1,787 @@
# 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");
```
这确保对话与世界状态的变化原子性发生,不存在对话播完但状态未更新的窗口。