chore: initial commit
This commit is contained in:
787
Docs/Design/15_DialogueSystem.md
Normal file
787
Docs/Design/15_DialogueSystem.md
Normal 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. [交互提示 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; // 对话文本(支持 <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");
|
||||
```
|
||||
|
||||
这确保对话与世界状态的变化原子性发生,不存在对话播完但状态未更新的窗口。
|
||||
Reference in New Issue
Block a user