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

744 lines
26 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.
# 38 · NPC 任务链系统Quest System
> **命名空间** `BaseGames.Quest`
> **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md)
> **依赖** `BaseGames.Core.Events` · `BaseGames.Dialogue`InteractableNPC、RelationshipManager· `BaseGames.World`SaveManager· `BaseGames.Player`PlayerStats
---
## 目录
1. [系统总览](#1-系统总览)
2. [QuestSO — 任务数据](#2-questso--任务数据)
3. [QuestObjectiveSO — 目标类型](#3-questobjectiveso--目标类型)
4. [QuestManager — 核心管理器](#4-questmanager--核心管理器)
5. [QuestGiver — 任务 NPC](#5-questgiver--任务-npc)
6. [任务分支与条件](#6-任务分支与条件)
7. [好感度集成](#7-好感度集成)
8. [任务日志 UI](#8-任务日志-ui)
9. [SaveData 集成](#9-savedata-集成)
10. [事件频道](#10-事件频道)
11. [编辑器友好设计](#11-编辑器友好设计)
---
## 1. 系统总览
任务链系统负责追踪 **NPC 委托型任务**(探索交互/委托/支线)的进度与状态,与好感度系统、对话条件系统深度集成,是非线性世界叙事的骨架。
```
任务链系统职责:
├─ QuestSO → 任务定义(名称/目标链/奖励/分支条件)
├─ QuestObjectiveSO → 单步目标(与谁对话/击败谁/收集什么/到达哪里)
├─ QuestManager → 运行时追踪所有任务状态,响应游戏事件推进进度
├─ QuestGiver → 扩展 InteractableNPC负责发布/完成任务、切换对话
└─ QuestLogUI → 任务日志界面(暂停菜单中的"任务"页)
```
**设计原则**
- 任务状态为只进不退:`Unavailable → Available → Active → Completed / Failed`
- 每个目标是独立 SO可在多个任务之间共享
- 任务进度完全事件驱动QuestManager 只监听事件频道,不主动轮询
- 任务完成奖励通过 `RewardHandler` 分发,不在 QuestManager 中直接操作 PlayerStats
---
## 2. QuestSO — 任务数据
```csharp
[CreateAssetMenu(menuName = "Quest/Quest")]
public class QuestSO : ScriptableObject
{
[Header("标识")]
public string questId; // 唯一 ID如 "Quest_FindMushroom"
public string displayName;
[TextArea(2, 6)]
public string description;
public Sprite icon; // 任务日志图标(可选)
[Header("目标链")]
public QuestObjectiveSO[] objectives; // 按顺序执行,全部完成 = 任务可交完
[Header("分支(可选)")]
public QuestBranch[] branches; // 任务完成后的后续任务(多选一)
[Header("前置条件")]
public string[] prerequisiteQuestIds; // 所有前置任务必须已完成才可接取
public int minAffinityToAccept; // 发布该任务的 NPC 好感度门槛0 = 无限制)
[Header("奖励")]
public RewardSO reward; // 奖励 SO见下方
[Header("配置")]
public bool canFail; // 是否有失败条件(默认 false
public QuestObjectiveSO failCondition; // 若 canFail = true此目标触发 = 任务失败
}
[Serializable]
public class QuestBranch
{
public string conditionQuestId; // 若该任务已完成 → 走此分支(空 = 默认分支)
public QuestSO nextQuest; // 解锁的后续任务
public string npcDialogueKey; // 触发的 NPC 对话 key切换 NPC 台词)
}
```
### RewardSO奖励数据
```csharp
[CreateAssetMenu(menuName = "Quest/Reward")]
public class RewardSO : ScriptableObject
{
public int geo; // Geo 奖励
public int soulBonus; // 灵魂槽扩展(+max
public ItemSO[] items; // 物品奖励(工具/护符等)
public int affinityBonus; // 完成后对发布 NPC 好感度增量
public string unlockDialogueKey;// 解锁 NPC 新台词集合 key
public AbilityType unlockedAbility; // 若非 None → 解锁玩家能力
}
```
---
## 3. QuestObjectiveSO — 目标类型
所有目标继承 `QuestObjectiveSO` 抽象类:
```csharp
public abstract class QuestObjectiveSO : ScriptableObject
{
[Header("目标描述(日志显示)")]
public string objectiveText; // e.g. "与旅人 Aldric 对话"
public bool isOptional; // 可选目标(完成有额外奖励,不完成不影响主线)
/// <summary>
/// 向 QuestManager 注册需要监听的事件频道。
/// QuestManager 在激活目标时调用此方法。
/// </summary>
public abstract void RegisterListeners(IQuestObjectiveListener listener);
public abstract void UnregisterListeners(IQuestObjectiveListener listener);
/// <summary>
/// 返回是否已完成(数据持久化状态,供存档读取后还原)
/// </summary>
public abstract bool EvaluateCompletion(QuestObjectiveState state);
}
public interface IQuestObjectiveListener
{
void OnObjectiveTriggered(QuestObjectiveSO objective);
}
```
### 3.1 内置目标类型
#### TalkToNPCObjective
```csharp
[CreateAssetMenu(menuName = "Quest/Objectives/TalkToNPC")]
public class TalkToNPCObjective : QuestObjectiveSO
{
public string targetNpcId; // 目标 NPC 的 npcId
public override void RegisterListeners(IQuestObjectiveListener listener)
=> DialogueManager.Instance.OnNPCDialogueCompleted += id =>
{
if (id == targetNpcId)
listener.OnObjectiveTriggered(this);
};
public override void UnregisterListeners(IQuestObjectiveListener listener)
=> DialogueManager.Instance.OnNPCDialogueCompleted -= id =>
{
if (id == targetNpcId)
listener.OnObjectiveTriggered(this);
};
public override bool EvaluateCompletion(QuestObjectiveState state)
=> state.completed;
}
```
#### DefeatEnemyObjective
```csharp
[CreateAssetMenu(menuName = "Quest/Objectives/DefeatEnemy")]
public class DefeatEnemyObjective : QuestObjectiveSO
{
public string enemyTypeId; // 目标敌人类型(空 = 任意敌人)
public int requiredCount; // 需击败数量
// 通过 OnEnemyDefeated 事件频道监听
public override void RegisterListeners(IQuestObjectiveListener listener) { /* ... */ }
public override void UnregisterListeners(IQuestObjectiveListener listener) { /* ... */ }
public override bool EvaluateCompletion(QuestObjectiveState state)
=> state.progressCount >= requiredCount;
}
```
#### CollectItemObjective
```csharp
[CreateAssetMenu(menuName = "Quest/Objectives/CollectItem")]
public class CollectItemObjective : QuestObjectiveSO
{
public string itemId;
public int requiredAmount;
// 通过 OnCollectiblePickedUp 事件频道监听
}
```
#### ReachAreaObjective
```csharp
[CreateAssetMenu(menuName = "Quest/Objectives/ReachArea")]
public class ReachAreaObjective : QuestObjectiveSO
{
public string sceneId; // 目标场景名(进入即完成)
public string triggerZoneId; // 或场景内特定 TriggerZone 的 ID
// 通过 OnRoomEntered / 自定义 TriggerZone 事件监听
}
```
#### UseSkillObjective
```csharp
[CreateAssetMenu(menuName = "Quest/Objectives/UseSkill")]
public class UseSkillObjective : QuestObjectiveSO
{
public string skillId; // 如 "Parry" / "Tool_Slingshot"
public int requiredCount; // 使用次数(演示用技能的任务)
}
```
---
## 4. QuestManager — 核心管理器
```csharp
namespace BaseGames.Quest
{
public class QuestManager : MonoBehaviour, IQuestObjectiveListener
{
[Header("事件频道—订阅")]
[SerializeField] StringEventChannelSO _onNPCDialogueCompleted;
[SerializeField] StringEventChannelSO _onEnemyDefeated;
[SerializeField] StringEventChannelSO _onCollectiblePickedUp;
[SerializeField] StringEventChannelSO _onRoomEntered;
[SerializeField] StringEventChannelSO _onSkillUsed;
[Header("事件频道—发布")]
[SerializeField] QuestEventChannelSO _onQuestStarted;
[SerializeField] QuestEventChannelSO _onQuestCompleted;
[SerializeField] QuestEventChannelSO _onQuestFailed;
[SerializeField] VoidEventChannelSO _onObjectiveUpdated;
// 运行时数据
readonly Dictionary<string, QuestRuntimeState> _quests = new();
// ──────────────── 生命周期 ─────────────────────────────────────────
void Awake()
{
// 从 SaveData 恢复状态
LoadFromSave();
}
void OnEnable()
{
_onNPCDialogueCompleted.OnEventRaised += HandleNPCDialogue;
_onEnemyDefeated.OnEventRaised += HandleEnemyDefeated;
_onCollectiblePickedUp.OnEventRaised += HandleCollectible;
_onRoomEntered.OnEventRaised += HandleRoomEntered;
_onSkillUsed.OnEventRaised += HandleSkillUsed;
}
void OnDisable()
{
_onNPCDialogueCompleted.OnEventRaised -= HandleNPCDialogue;
_onEnemyDefeated.OnEventRaised -= HandleEnemyDefeated;
_onCollectiblePickedUp.OnEventRaised -= HandleCollectible;
_onRoomEntered.OnEventRaised -= HandleRoomEntered;
_onSkillUsed.OnEventRaised -= HandleSkillUsed;
}
// ──────────────── 核心接口 ─────────────────────────────────────────
/// <summary>NPC 调用:接受任务</summary>
public bool TryAcceptQuest(QuestSO quest, string giverNpcId)
{
if (_quests.ContainsKey(quest.questId))
return false; // 已接取或已完成
if (!ArePrerequisitesMet(quest))
return false;
var state = new QuestRuntimeState(quest, giverNpcId);
_quests[quest.questId] = state;
ActivateCurrentObjective(state);
_onQuestStarted.Raise(quest);
SaveToData();
return true;
}
/// <summary>NPC 调用:完成任务(交任务时)</summary>
public bool TryCompleteQuest(string questId)
{
if (!_quests.TryGetValue(questId, out var state)) return false;
if (!state.IsReadyToComplete) return false;
state.Status = QuestStatus.Completed;
UnregisterObjectiveListeners(state);
_onQuestCompleted.Raise(state.Quest);
RewardHandler.Instance.GrantReward(state.Quest.reward, state.GiverNpcId);
// 解锁分支后续任务
var branch = SelectBranch(state.Quest);
if (branch?.nextQuest != null)
MakeQuestAvailable(branch.nextQuest.questId);
SaveToData();
return true;
}
// ──────────────── IQuestObjectiveListener ──────────────────────────
public void OnObjectiveTriggered(QuestObjectiveSO objective)
{
foreach (var (id, state) in _quests)
{
if (state.Status != QuestStatus.Active) continue;
var current = state.CurrentObjective;
if (current != objective) continue;
// 推进进度
state.AdvanceProgress();
if (state.IsCurrentObjectiveComplete())
state.MoveToNextObjective();
if (state.IsReadyToComplete)
_onObjectiveUpdated.Raise(); // 通知 UI 任务可交完
else
_onObjectiveUpdated.Raise(); // 通知 UI 进度更新
SaveToData();
break;
}
}
// ──────────────── 内部辅助 ──────────────────────────────────────────
bool ArePrerequisitesMet(QuestSO quest)
{
foreach (var id in quest.prerequisiteQuestIds)
if (!IsQuestCompleted(id)) return false;
return true;
}
void ActivateCurrentObjective(QuestRuntimeState state)
{
state.CurrentObjective?.RegisterListeners(this);
}
void UnregisterObjectiveListeners(QuestRuntimeState state)
{
state.CurrentObjective?.UnregisterListeners(this);
}
QuestBranch SelectBranch(QuestSO quest)
{
if (quest.branches == null || quest.branches.Length == 0) return null;
foreach (var b in quest.branches)
if (!string.IsNullOrEmpty(b.conditionQuestId) && IsQuestCompleted(b.conditionQuestId))
return b;
// 返回默认分支conditionQuestId 为空的最后一个)
return Array.Find(quest.branches, b => string.IsNullOrEmpty(b.conditionQuestId));
}
bool IsQuestCompleted(string questId)
=> _quests.TryGetValue(questId, out var s) && s.Status == QuestStatus.Completed;
void MakeQuestAvailable(string questId) { /* 在 _availableQuestIds 集合中标记,供 QuestGiver 查询 */ }
// ──────────────── 事件处理器(转发到各 Objective──────────────────
void HandleNPCDialogue(string npcId) => BroadcastEvent("NPC", npcId);
void HandleEnemyDefeated(string typeId) => BroadcastEvent("Enemy", typeId);
void HandleCollectible(string itemId) => BroadcastEvent("Item", itemId);
void HandleRoomEntered(string sceneId) => BroadcastEvent("Room", sceneId);
void HandleSkillUsed(string skillId) => BroadcastEvent("Skill", skillId);
void BroadcastEvent(string category, string id)
{
// 将事件转发给当前所有激活目标,让目标自行判断是否匹配
foreach (var state in _quests.Values)
{
if (state.Status != QuestStatus.Active) continue;
state.CurrentObjective?.OnEvent(category, id, this);
}
}
void LoadFromSave() { /* 从 SaveManager.Instance.GetQuestData() 重建 _quests */ }
void SaveToData() { SaveManager.Instance.SetQuestData(_quests.Values); }
}
}
```
### QuestRuntimeState运行时状态容器
```csharp
public class QuestRuntimeState
{
public QuestSO Quest { get; }
public string GiverNpcId { get; }
public QuestStatus Status { get; set; }
int _objectiveIndex;
readonly int[] _progressCounts; // 每个目标的当前计数(用于 Count 类目标)
public QuestObjectiveSO CurrentObjective
=> _objectiveIndex < Quest.objectives.Length
? Quest.objectives[_objectiveIndex]
: null;
public bool IsReadyToComplete
=> _objectiveIndex >= Quest.objectives.Length && Status == QuestStatus.Active;
public QuestRuntimeState(QuestSO quest, string giverNpcId)
{
Quest = quest;
GiverNpcId = giverNpcId;
Status = QuestStatus.Active;
_objectiveIndex = 0;
_progressCounts = new int[quest.objectives.Length];
}
public void AdvanceProgress() => _progressCounts[_objectiveIndex]++;
public bool IsCurrentObjectiveComplete()
=> CurrentObjective?.EvaluateCompletion(GetState(_objectiveIndex)) ?? true;
public void MoveToNextObjective()
{
CurrentObjective?.UnregisterListeners(null); // 注销旧目标
_objectiveIndex++;
CurrentObjective?.RegisterListeners(null); // 注册新目标
}
QuestObjectiveState GetState(int idx) => new QuestObjectiveState
{
completed = _progressCounts[idx] > 0,
progressCount = _progressCounts[idx],
};
}
public struct QuestObjectiveState
{
public bool completed;
public int progressCount;
}
public enum QuestStatus
{
Unavailable, // 前置条件未满足
Available, // 可接取NPC 有 ! 标志)
Active, // 进行中
Completed, // 已完成
Failed, // 已失败
}
```
---
## 5. QuestGiver — 任务 NPC
`QuestGiver` 继承 `InteractableNPC`,管理该 NPC 可以发布/完成的任务列表:
```csharp
namespace BaseGames.Quest
{
public class QuestGiver : InteractableNPC
{
[Header("任务")]
[SerializeField] QuestSO[] _offeredQuests; // 该 NPC 可发布的任务(按顺序尝试)
[SerializeField] string _npcId; // 与 Dialogue/Relationship 系统对齐
protected override void Interact_Internal(Transform player)
{
var pendingComplete = GetQuestReadyToComplete();
if (pendingComplete != null)
{
// 优先结算可完成的任务
PlayQuestCompleteDialogue(pendingComplete);
QuestManager.Instance.TryCompleteQuest(pendingComplete.questId);
return;
}
var available = GetAvailableQuest();
if (available != null)
{
// 提供新任务
PlayQuestAcceptDialogue(available);
QuestManager.Instance.TryAcceptQuest(available, _npcId);
return;
}
// 无任务 → 播放普通对话(含好感度对应台词)
base.Interact_Internal(player);
}
QuestSO GetQuestReadyToComplete()
{
foreach (var q in _offeredQuests)
if (QuestManager.Instance.IsReadyToComplete(q.questId))
return q;
return null;
}
QuestSO GetAvailableQuest()
{
foreach (var q in _offeredQuests)
if (QuestManager.Instance.CanAccept(q.questId))
return q;
return null;
}
void PlayQuestAcceptDialogue(QuestSO quest) =>
DialogueManager.Instance.Play(quest.questId + "_Accept");
void PlayQuestCompleteDialogue(QuestSO quest) =>
DialogueManager.Instance.Play(quest.questId + "_Complete");
}
}
```
**NPC 头顶图标规则**(由 `QuestIconController` 驱动):
| 状态 | 图标 | 颜色 |
|------|------|------|
| 可接新任务 | `!`(感叹号)| 黄色 |
| 玩家有可交付的任务 | `?`(问号)| 橙色 |
| 进行中(追踪中)| `⋯`(省略号)| 灰色 |
| 无任务 | 无图标 | — |
---
## 6. 任务分支与条件
### 分支示例:《寻找蘑菇》
```
Quest_FindMushroom
objectives:
[0] CollectItemObjective { itemId = "Item_GlowMushroom", requiredAmount = 3 }
[1] TalkToNPCObjective { targetNpcId = "NPC_Herbalist" }
branches:
[0] conditionQuestId = "Quest_DefeatForestBoss" → nextQuest = Quest_SpecialBrew
[1] conditionQuestId = ""(默认) → nextQuest = Quest_PoisonTip
```
`SelectBranch()``TryCompleteQuest` 时执行,根据当前存档状态选择一个分支解锁后续任务。
### 失败条件示例:《守护商队》
```
Quest_EscortCaravan
canFail = true
failCondition = DefeatEnemyObjective { enemyTypeId = "NPC_Caravan_Merchant", requiredCount = 1 }
(商队 NPC 被击败时任务失败)
```
---
## 7. 好感度集成
任务系统与 §13 `RelationshipManager` 深度整合:
| 时机 | 行为 |
|------|------|
| 接取任务 | `RelationshipManager.AddAffinity(npcId, +5)` |
| 完成任务 | `RelationshipManager.AddAffinity(npcId, reward.affinityBonus)` |
| 好感度解锁新任务 | `QuestSO.minAffinityToAccept` 门槛检查 |
| 任务失败 | `RelationshipManager.AddAffinity(npcId, -10)` |
| 完成任务后 NPC 对话 | 切换至 `reward.unlockDialogueKey` 对应的台词集合 |
```csharp
// RewardHandler 在发放奖励时同步更新好感度
void GrantReward(RewardSO reward, string npcId)
{
PlayerStats.Instance.AddGeo(reward.geo);
PlayerStats.Instance.AddMaxSoul(reward.soulBonus);
foreach (var item in reward.items)
InventoryManager.Instance.AddItem(item);
if (reward.affinityBonus != 0)
RelationshipManager.Instance.AddAffinity(npcId, reward.affinityBonus);
if (!string.IsNullOrEmpty(reward.unlockDialogueKey))
DialogueManager.Instance.UnlockDialogueSet(npcId, reward.unlockDialogueKey);
if (reward.unlockedAbility != AbilityType.None)
PlayerStats.Instance.UnlockAbility(reward.unlockedAbility);
}
```
---
## 8. 任务日志 UI
暂停菜单中的"任务"标签页:
```
┌─ 任务日志 ─────────────────────────────────────────────┐
│ [进行中] [已完成] │
│ ─────────────────────────────────────────────────── │
│ ► 寻找蘑菇 ⋯ 进行中 │
│ 目标:将 发光蘑菇 交给 草药师 Aldric │
│ 进度:[✓] 收集 3/3 发光蘑菇 │
│ [ ] 与 Aldric 对话 │
│ ─────────────────────────────────────────────────── │
│ ► 守护商队 ✓ 已完成 │
└────────────────────────────────────────────────────────┘
```
```csharp
public class QuestLogUI : MonoBehaviour
{
[SerializeField] QuestEventChannelSO _onQuestStarted;
[SerializeField] QuestEventChannelSO _onQuestCompleted;
[SerializeField] VoidEventChannelSO _onObjectiveUpdated;
[SerializeField] QuestEntryWidget _entryPrefab;
[SerializeField] Transform _activeContainer;
[SerializeField] Transform _completedContainer;
void OnEnable()
{
_onQuestStarted.OnEventRaised += OnQuestStarted;
_onQuestCompleted.OnEventRaised += OnQuestCompleted;
_onObjectiveUpdated.OnEventRaised += Refresh;
}
void OnDisable()
{
_onQuestStarted.OnEventRaised -= OnQuestStarted;
_onQuestCompleted.OnEventRaised -= OnQuestCompleted;
_onObjectiveUpdated.OnEventRaised -= Refresh;
}
void Refresh()
{
// 从 QuestManager 获取当前所有任务状态,重建 UI 条目
ClearContainer(_activeContainer);
ClearContainer(_completedContainer);
foreach (var state in QuestManager.Instance.GetAllQuests())
{
var entry = Instantiate(_entryPrefab,
state.Status == QuestStatus.Completed ? _completedContainer : _activeContainer);
entry.Bind(state);
}
}
void OnQuestStarted(QuestSO _) => Refresh();
void OnQuestCompleted(QuestSO _) => Refresh();
void ClearContainer(Transform t)
{
foreach (Transform c in t)
Destroy(c.gameObject);
}
}
```
---
## 9. SaveData 集成
```json
"quests": {
"active": [
{
"questId": "Quest_FindMushroom",
"giverNpcId": "NPC_Herbalist",
"objectiveIndex": 1,
"progressCounts": [3, 0]
}
],
"completed": ["Quest_EscortCaravan", "Quest_OldManLetter"],
"failed": [],
"availableQuestIds": ["Quest_SpecialBrew"]
}
```
```csharp
// SaveManager 扩展
public void SetQuestData(IEnumerable<QuestRuntimeState> states)
{
_saveData.quests.active = states
.Where(s => s.Status == QuestStatus.Active)
.Select(s => s.ToSaveData()).ToList();
_saveData.quests.completed = states
.Where(s => s.Status == QuestStatus.Completed)
.Select(s => s.Quest.questId).ToList();
WriteDirty();
}
```
---
## 10. 事件频道
| 频道资产 | 类型 | 发布方 | 主要订阅方 |
|---------|------|--------|----------|
| `OnQuestStarted.asset` | `QuestEventChannelSO` | `QuestManager` | `QuestLogUI`(添加条目)、`HUD`(屏幕提示)|
| `OnQuestCompleted.asset` | `QuestEventChannelSO` | `QuestManager` | `QuestLogUI`(移至完成列)、`AchievementManager` |
| `OnQuestFailed.asset` | `QuestEventChannelSO` | `QuestManager` | `QuestLogUI``DialogueManager`(触发失败台词)|
| `OnObjectiveUpdated.asset` | `VoidEventChannelSO` | `QuestManager` | `QuestLogUI`(刷新进度)、`HUD`(可交付提示)|
```csharp
[CreateAssetMenu(menuName = "Events/QuestEventChannel")]
public class QuestEventChannelSO : ScriptableObject
{
public event Action<QuestSO> OnEventRaised;
public void Raise(QuestSO quest) => OnEventRaised?.Invoke(quest);
}
```
---
## 11. 编辑器友好设计
### QuestManager InspectorPlay Mode
```
┌─ QuestManager ─────────────────────────────────────────────┐
│ 进行中任务: │
│ ├─ Quest_FindMushroom [目标 1/2] 进度: 3/3 │
│ └─ Quest_GuardAldric [目标 0/1] 进度: ─ │
│ 已完成: 3 失败: 0 │
│ [强制完成选中任务] [重置选中任务] [解锁任务 ▼] │
└────────────────────────────────────────────────────────────┘
```
### 新增任务 SOP零代码
1. `Create → Quest/Quest` → 填写 `questId``displayName``objectives`
2. 为每个目标 `Create → Quest/Objectives/{Type}`
3. 将任务 SO 拖入目标 NPC 的 `QuestGiver._offeredQuests`
4. 若有奖励:`Create → Quest/Reward`,填写 Geo / Item / Affinity
### 对话文件命名规范
```
Assets/Data/Dialogue/{QuestId}_Accept.asset ← 接任务台词
Assets/Data/Dialogue/{QuestId}_Complete.asset ← 完成任务台词
Assets/Data/Dialogue/{QuestId}_InProgress.asset ← 进行中每次对话台词
Assets/Data/Dialogue/{QuestId}_Failed.asset ← 失败台词(可选)
```