25 KiB
15 · 对话系统与交互接口
命名空间
BaseGames.Dialogue
所属文档集 ← 返回索引 · 总览
依赖BaseGames.Core.Events·BaseGames.UI(DialogueBox)·BaseGames.Input(Action Map 切换)
目录
- 系统总览
- IInteractable — 通用交互接口
- 交互提示 UI(InteractionPrompt)
- 数据结构:DialogueLine / DialogueSequenceSO
- DialogueManager
- DialogueUI(对话框组件)
- InteractableNPC
- 对话触发条件
- 输入 Action Map 切换
- 与 ShopNPC 集成
- 事件频道
- 编辑器友好设计
- NPC 好感度系统
1. 系统总览
对话系统职责:
├─ IInteractable 接口 → 统一所有可交互物件(NPC/告示牌/机关)的交互入口
├─ InteractionPrompt UI → 玩家靠近可交互物件时显示"按 [F] 交互"提示
├─ DialogueSequenceSO → 对话内容数据化,策划直接在 Inspector 中编辑台词
├─ DialogueManager → 管理对话播放状态,驱动 DialogueUI
├─ DialogueUI → 打字机效果文字框、说话人名称、继续提示
└─ 对话条件系统 → 根据游戏进程显示不同对话内容(P1 分支)
2. IInteractable — 通用交互接口
统一所有可交互物件的接口,PlayerInteractState 只需知道此接口:
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 实例),默认隐藏:
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(单行对话数据)
[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
[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 场景,管理对话播放状态:
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):
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:
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 行为):
// DialogueManager.StartDialogue() 中:
// 若 sequence 的 owner 是 InteractableNPC,则 npc.FaceToward(player.position)
8. 对话触发条件
P1 扩展:根据游戏进程显示不同对话内容(如击败 Boss 后 NPC 台词变化):
[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] 翻页 |
| 暂停菜单 | ❌ 禁用 | ✅ 启用 | 菜单导航 |
// 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:
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
/// <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 字段:
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 扩展
// 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 扩展
"relationships": {
"npc_merchant": 72,
"npc_elder": 45,
"npc_hidden_master": 10
}
// 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:
[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 的对话层集成说明。
NarrativeNPC是对InteractableNPC的扩展,支持基于WorldStateRegistry全局标志的对话版本切换。
14.1 NarrativeNPC 与 InteractableNPC 的关系
InteractableNPC (基类)
└── NarrativeNPC (扩展)
├── 继承好感度系统(§13)
├── 继承基础对话触发(§8)
└── 新增:WorldStateRegistry 标志驱动的对话版本选择
14.2 DialogueVersion(世界状态对话版本)
[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 对话选择逻辑
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():
// 在 DialogueLine 中(或 EventChainSO Action 中)
WorldStateRegistry.Instance.SetFlag("npc_elder_migrated_to_ruins");
NPCMigrationService.Instance.TriggerMigration("npc_elder", "ruins_temple");
这确保对话与世界状态的变化原子性发生,不存在对话播完但状态未更新的窗口。