# 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. [交互提示 UI(InteractionPrompt)](#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. 交互提示 UI(InteractionPrompt) 当玩家进入 `IInteractable` 的交互范围(Trigger Collider)时,在物件上方显示交互提示图标: ``` InteractionPrompt Prefab(World 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; // 对话文本(支持 等 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 /// /// 单例,负责读写全局 NPC 好感度数据。 /// public class RelationshipManager : MonoBehaviour { public static RelationshipManager Instance { get; private set; } // Key = npcId readonly Dictionary _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 GetRelationships() => _saveData.relationships ?? new Dictionary(); public void SetRelationships(Dictionary 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"); ``` 这确保对话与世界状态的变化原子性发生,不存在对话播完但状态未更新的窗口。