- QuestManager.ApplyAffinity: giverNpc.npcId 为空时改为 LogWarning+return,不再静默丢弃好感度奖励 - QuestManager.UnlockBranches: 分支对话 npcId 为空时输出 LogWarning,提示开发者可能误推进对话类目标 - QuestGiver.InteractPrompt: Available 状态调用 GetQuestLockInfo,亲密度/前置未满足时显示锁定原因而非'接受任务' - QuestGiver.Interact_Internal: Available 状态加锁定检查防卫,锁定时提前返回;新增 _allowAbandon 字段(默认 false) - QuestGiver: Active+未完成+_allowAbandon=true 时显示'放弃任务'并触发 AbandonQuest,接入已有 AbandonQuest 接口 - DialogueManager: 新增 _waitSequenceTimeout 缓存字段,Awake 预创建避免每次 PlayImmediate 分配 WaitForSeconds - DialogueManager: 新增 _currentNpcId 字段,PlayImmediate 写入、EndDialogue/ForceEnd 清空 - IDialogueService + DialogueManager: 暴露 CurrentNpcId 只读属性,供外部系统主动查询当前对话 NPC - QuestSO.OnValidate: 对空 displayNameKey/descriptionKey 输出 LogWarning,防止 UI 显示空文本 - 新增 QuestOverviewEditorWindow: BaseGames/Quest/Quest Overview,列出全部 QuestSO,支持搜索/分类过滤; Play Mode 下读取 IQuestManager 运行时状态并着色显示;Edit Mode 高亮配置错误行;单击 Ping、双击 Select Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
354 lines
17 KiB
C#
354 lines
17 KiB
C#
using System;
|
||
using UnityEngine;
|
||
using BaseGames.Core;
|
||
using BaseGames.Dialogue;
|
||
|
||
namespace BaseGames.Quest
|
||
{
|
||
/// <summary>
|
||
/// 任务定义 SO(架构 22_QuestChallengeModule §2)。
|
||
/// 资产路径: Assets/ScriptableObjects/Quest/Quest_{questId}.asset
|
||
/// </summary>
|
||
[CreateAssetMenu(menuName = "BaseGames/Quest/Quest")]
|
||
public class QuestSO : ScriptableObject
|
||
{
|
||
[Header("标识")]
|
||
[Tooltip("任务唯一 ID,如 \"Quest_FindMushroom\"。运行时由 QuestManager 以此为键索引,必须全局唯一。")]
|
||
public string questId;
|
||
|
||
[Tooltip("发布/完成该任务的 NPC(直接引用 NpcSO 资产)。\n" +
|
||
"用于完成任务后向该 NPC 应用 affinityBonus,及 CanAccept 好感度门槛检查。\n" +
|
||
"留空时跳过好感度相关逻辑。")]
|
||
public NpcSO giverNpc;
|
||
|
||
/// <summary>运行时使用的 NPC ID(来自 giverNpc.npcId)。</summary>
|
||
public string GiverNpcId => giverNpc != null ? giverNpc.npcId : string.Empty;
|
||
|
||
[Tooltip("本地化 Key,格式如 \"Quest_FindMushroom_Name\"。通过 LocalizationManager.Get(displayNameKey, \"Quest\") 显示。")]
|
||
public string displayNameKey;
|
||
[Tooltip("本地化 Key,格式如 \"Quest_FindMushroom_Desc\"。通过 LocalizationManager.Get(descriptionKey, \"Quest\") 显示。")]
|
||
public string descriptionKey;
|
||
[Tooltip("任务图标,显示在日志 UI 和 DataHub 列表中(可选)。")]
|
||
public Sprite icon;
|
||
|
||
[Header("分类")]
|
||
[Tooltip("任务分类,供任务日志 UI 分区显示和 DataHub 快速过滤使用。\n" +
|
||
" Main = 主线任务(必做,推动剧情)\n" +
|
||
" Side = 支线任务(可选,丰富世界观)\n" +
|
||
" Daily = 日常/重复任务(可重置)\n" +
|
||
" Hidden = 隐藏任务(不主动显示,触发后才出现)")]
|
||
public QuestCategory category = QuestCategory.Side;
|
||
|
||
[Header("目标链")]
|
||
[Tooltip("按顺序完成的目标列表。全部非可选目标完成后任务可交付。每个目标为独立的 QuestObjectiveSO 资产。")]
|
||
public QuestObjectiveSO[] objectives;
|
||
|
||
[Header("前置条件")]
|
||
[Tooltip("任务前置条件(前置任务依赖 + 世界标志依赖)。留空表示无前置限制。")]
|
||
public QuestPrerequisite prerequisites = new QuestPrerequisite();
|
||
|
||
[Tooltip("接取本任务所需的 NPC 好感度下限(0 = 无限制)。由好感度系统提供实际数值。")]
|
||
public int minAffinityToAccept;
|
||
|
||
[Header("奖励")]
|
||
[Tooltip("任务完成时发放的奖励(RewardSO)。留空表示无奖励。\n" +
|
||
"QuestManager.CompleteQuest() 调用 reward.Apply(rewardTarget),通过 IRewardTarget 接口发放。")]
|
||
public RewardSO reward;
|
||
|
||
[Header("失败条件(可选)")]
|
||
[Tooltip("勾选后,failConditions 中任意一个目标完成,本任务立即失败并触发 EVT_QuestFailed 事件。")]
|
||
public bool canFail;
|
||
[Tooltip("失败判定目标列表(任意一个达成即失败)。canFail=true 时有效。\n" +
|
||
"支持多个失败条件(如「BOSS 在限时内未被击败」OR「关键 NPC 死亡」)。")]
|
||
public QuestObjectiveSO[] failConditions;
|
||
|
||
[Header("接取/完成对话")]
|
||
[Tooltip("玩家接取任务时自动触发的 NPC 对话序列(如 NPC 委托台词)。\n" +
|
||
"AcceptQuest 成功后立即播放;留空则不触发。")]
|
||
public DialogueSequenceSO acceptDialogueSequence;
|
||
|
||
[Header("完成后续任务(分支)")]
|
||
[Tooltip("完成本任务后解锁的后续任务。conditionQuest=null 为默认分支;多个分支可同时满足(允许同时解锁多条支线)。")]
|
||
public QuestBranch[] branches;
|
||
|
||
// ── 编辑器校验 ────────────────────────────────────────────────────────
|
||
#if UNITY_EDITOR
|
||
// questId → 资产路径,5 秒 TTL,跨所有 QuestSO.OnValidate 共用。
|
||
// 重复检测时只需将缓存路径与自身路径比对(O(1)),无需全量扫描。
|
||
private static System.Collections.Generic.Dictionary<string, string> s_questIdToPath;
|
||
private static double s_questIdsCacheTime = -10.0;
|
||
|
||
private static System.Collections.Generic.Dictionary<string, string> GetQuestIdCache()
|
||
{
|
||
double now = UnityEditor.EditorApplication.timeSinceStartup;
|
||
if (s_questIdToPath != null && now - s_questIdsCacheTime < 5.0)
|
||
return s_questIdToPath;
|
||
|
||
s_questIdToPath = new System.Collections.Generic.Dictionary<string, string>(System.StringComparer.Ordinal);
|
||
string[] guids = UnityEditor.AssetDatabase.FindAssets("t:QuestSO");
|
||
foreach (var guid in guids)
|
||
{
|
||
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid);
|
||
var q = UnityEditor.AssetDatabase.LoadAssetAtPath<QuestSO>(path);
|
||
if (q != null && !string.IsNullOrEmpty(q.questId) && !s_questIdToPath.ContainsKey(q.questId))
|
||
s_questIdToPath[q.questId] = path;
|
||
}
|
||
s_questIdsCacheTime = now;
|
||
return s_questIdToPath;
|
||
}
|
||
|
||
private void OnValidate()
|
||
{
|
||
if (string.IsNullOrWhiteSpace(questId))
|
||
{
|
||
Debug.LogWarning($"[QuestSO] '{name}' 缺少 questId,保存前请填写。", this);
|
||
return;
|
||
}
|
||
|
||
// 检测重复 questId:缓存路径 vs 自身路径比对(O(1)),5 秒内无需重扫。
|
||
var cache = GetQuestIdCache();
|
||
string myPath = UnityEditor.AssetDatabase.GetAssetPath(this);
|
||
if (!string.IsNullOrEmpty(myPath) &&
|
||
cache.TryGetValue(questId, out var existingPath) &&
|
||
existingPath != myPath)
|
||
{
|
||
Debug.LogError(
|
||
$"[QuestSO] questId '{questId}' 与 " +
|
||
$"'{System.IO.Path.GetFileNameWithoutExtension(existingPath)}' 重复!请修改其中一个。", this);
|
||
s_questIdsCacheTime = -10.0;
|
||
}
|
||
|
||
// 本地化 Key 完整性检查:空 Key 会导致 UI 显示空文本(未本地化内容)
|
||
if (string.IsNullOrWhiteSpace(displayNameKey))
|
||
Debug.LogWarning(
|
||
$"[QuestSO] '{name}'(questId='{questId}')的 displayNameKey 为空," +
|
||
"任务日志 UI 将显示空白名称。请填写本地化 Key,如 \"Quest_{questId}_Name\"。", this);
|
||
if (string.IsNullOrWhiteSpace(descriptionKey))
|
||
Debug.LogWarning(
|
||
$"[QuestSO] '{name}'(questId='{questId}')的 descriptionKey 为空," +
|
||
"任务详情 UI 将显示空白描述。请填写本地化 Key,如 \"Quest_{questId}_Desc\"。", this);
|
||
|
||
ValidateObjectiveIds();
|
||
ValidatePrerequisiteCycles();
|
||
ValidateBranchCycles();
|
||
}
|
||
|
||
private void ValidateObjectiveIds()
|
||
{
|
||
if (objectives == null || objectives.Length == 0) return;
|
||
var seen = new System.Collections.Generic.HashSet<string>(System.StringComparer.Ordinal);
|
||
for (int i = 0; i < objectives.Length; i++)
|
||
{
|
||
var obj = objectives[i];
|
||
if (obj == null || string.IsNullOrEmpty(obj.objectiveId)) continue;
|
||
if (!seen.Add(obj.objectiveId))
|
||
Debug.LogError(
|
||
$"[QuestSO] '{name}' 的 objectives[{i}] objectiveId '{obj.objectiveId}' " +
|
||
"在本任务内重复!同一个 ObjectiveSO 资产被引用多次会导致进度互相覆盖," +
|
||
"请为每个目标使用独立的 SO 资产。", this);
|
||
}
|
||
|
||
// 验证 prerequisiteObjectiveId 引用的 objectiveId 必须存在于本任务中
|
||
for (int i = 0; i < objectives.Length; i++)
|
||
{
|
||
var obj = objectives[i];
|
||
if (obj == null || string.IsNullOrEmpty(obj.prerequisiteObjectiveId)) continue;
|
||
if (!seen.Contains(obj.prerequisiteObjectiveId))
|
||
Debug.LogError(
|
||
$"[QuestSO] '{name}' 的 objectives[{i}].prerequisiteObjectiveId " +
|
||
$"'{obj.prerequisiteObjectiveId}' 在本任务中不存在!请检查目标ID是否填写正确。", this);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 检测前置任务链是否形成循环依赖(如 A 前置 B、B 前置 A)。
|
||
/// 循环会导致两个任务互相锁定,运行时无法被接取,属于配置错误。
|
||
/// </summary>
|
||
private void ValidatePrerequisiteCycles()
|
||
{
|
||
if (string.IsNullOrEmpty(questId)) return;
|
||
|
||
var visited = new System.Collections.Generic.HashSet<string>(System.StringComparer.Ordinal);
|
||
visited.Add(questId);
|
||
|
||
QuestSO[] deps = prerequisites.questDependencies;
|
||
if (deps == null) return;
|
||
|
||
foreach (var dep in deps)
|
||
{
|
||
if (dep == null) continue;
|
||
if (HasPrerequisiteCycle(dep, visited))
|
||
{
|
||
Debug.LogError(
|
||
$"[QuestSO] '{name}'(questId='{questId}')的前置任务链存在循环依赖!" +
|
||
$"前置任务 '{dep.name}' 最终指回自身或已访问任务," +
|
||
"运行时将导致任务无法被接取。请检查 prerequisites 配置。", this);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 深度优先遍历前置链,检测是否存在环路。
|
||
/// <para>已访问节点集 <paramref name="visited"/> 在回溯时移除,保证同一链条中不误报平行分支。</para>
|
||
/// <para><paramref name="depth"/> 超过 32 层时停止递归并输出警告,防止编辑器因超深嵌套卡顿。</para>
|
||
/// </summary>
|
||
private static bool HasPrerequisiteCycle(QuestSO quest,
|
||
System.Collections.Generic.HashSet<string> visited, int depth = 0)
|
||
{
|
||
if (depth > 32)
|
||
{
|
||
Debug.LogError($"[QuestSO] 前置链深度超过 32 层(路径末端:'{quest.name}'),已视为存在循环依赖并中止检测。请减少任务链深度。");
|
||
return true;
|
||
}
|
||
if (string.IsNullOrEmpty(quest.questId)) return false;
|
||
if (!visited.Add(quest.questId)) return true; // 已在当前路径上 = 环路
|
||
|
||
QuestSO[] deps = quest.prerequisites.questDependencies;
|
||
|
||
if (deps != null)
|
||
{
|
||
foreach (var dep in deps)
|
||
{
|
||
if (dep != null && HasPrerequisiteCycle(dep, visited, depth + 1))
|
||
return true;
|
||
}
|
||
}
|
||
visited.Remove(quest.questId); // 回溯
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 检测 branches[].nextQuest 解锁链是否形成循环(如 A 完成解锁 B,B 完成解锁 A)。
|
||
/// 循环会导致运行时 UnlockBranches 无限递归设置任务状态,属于配置错误。
|
||
/// </summary>
|
||
private void ValidateBranchCycles()
|
||
{
|
||
if (branches == null || branches.Length == 0) return;
|
||
if (string.IsNullOrEmpty(questId)) return;
|
||
|
||
foreach (var branch in branches)
|
||
{
|
||
if (branch?.nextQuest == null) continue;
|
||
var visited = new System.Collections.Generic.HashSet<string>(System.StringComparer.Ordinal);
|
||
visited.Add(questId);
|
||
if (HasBranchCycle(branch.nextQuest, visited))
|
||
{
|
||
Debug.LogError(
|
||
$"[QuestSO] '{name}'(questId='{questId}')的分支 nextQuest '{branch.nextQuest.name}' " +
|
||
"解锁链存在循环!最终将指回自身或已访问任务,运行时 UnlockBranches 会进入无限递归。" +
|
||
"请检查 branches[].nextQuest 配置。", this);
|
||
return; // 一次只报首个问题
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 深度优先遍历 branches[].nextQuest 链,检测是否存在环路(DFS 回溯)。
|
||
/// <para><paramref name="depth"/> 超过 32 层时停止递归并输出警告,防止编辑器因超深嵌套卡顿。</para>
|
||
/// </summary>
|
||
private static bool HasBranchCycle(QuestSO quest,
|
||
System.Collections.Generic.HashSet<string> visited, int depth = 0)
|
||
{
|
||
if (depth > 32)
|
||
{
|
||
Debug.LogError($"[QuestSO] 分支链深度超过 32 层(路径末端:'{quest.name}'),已视为存在循环依赖并中止检测。请减少分支链深度。");
|
||
return true;
|
||
}
|
||
if (string.IsNullOrEmpty(quest.questId)) return false;
|
||
if (!visited.Add(quest.questId)) return true; // 已在路径上 = 环路
|
||
|
||
if (quest.branches != null)
|
||
{
|
||
foreach (var branch in quest.branches)
|
||
{
|
||
if (branch?.nextQuest != null && HasBranchCycle(branch.nextQuest, visited, depth + 1))
|
||
return true;
|
||
}
|
||
}
|
||
visited.Remove(quest.questId); // 回溯
|
||
return false;
|
||
}
|
||
#endif
|
||
}
|
||
|
||
[Serializable]
|
||
public class QuestBranch
|
||
{
|
||
[Tooltip("条件任务:该任务已 Completed 时走本分支(留空 = 默认分支,总是满足)。")]
|
||
public QuestSO conditionQuest;
|
||
[Tooltip("世界状态标志条件判断逻辑:\n" +
|
||
" And(默认)= 全部 conditionFlagEntries 均满足才走本分支\n" +
|
||
" Or = 任意一个 conditionFlagEntry 满足即可走本分支")]
|
||
public BaseGames.Core.WorldStateFlagLogic conditionFlagsLogic = BaseGames.Core.WorldStateFlagLogic.And;
|
||
[Tooltip("世界状态标志条件(支持 invert 取反)。按 conditionFlagsLogic 逻辑与 conditionQuest 共同决定分支是否激活。")]
|
||
public BranchFlagEntry[] conditionFlagEntries;
|
||
[Tooltip("本分支解锁的后续任务。满足所有条件后,此任务将被设为 Available。")]
|
||
public QuestSO nextQuest;
|
||
[Tooltip("完成本任务后触发的 NPC 对话序列(直接引用 DialogueSequenceSO 资产,无需手写 ID)。")]
|
||
public DialogueSequenceSO npcDialogueSequence;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 任务分支中单个世界状态标志条件,支持取反(NOT)逻辑。
|
||
/// </summary>
|
||
[Serializable]
|
||
public struct BranchFlagEntry
|
||
{
|
||
[Tooltip("世界状态标志 ID(由 ISaveService.GetFlag 查询)。")]
|
||
[BaseGames.Core.WorldStateFlag]
|
||
public string flagId;
|
||
[Tooltip("若勾选,则该标志为 false 时才满足条件(NOT 取反逻辑)。")]
|
||
public bool invert;
|
||
}
|
||
|
||
/// <summary>任务分类,供日志 UI 分区和 DataHub 过滤使用。</summary>
|
||
public enum QuestCategory
|
||
{
|
||
/// <summary>主线任务:必做,推动主剧情进展。</summary>
|
||
Main,
|
||
/// <summary>支线任务:可选,丰富世界观与 NPC 关系。</summary>
|
||
Side,
|
||
/// <summary>日常/重复任务:可在满足条件后重置。</summary>
|
||
Daily,
|
||
/// <summary>隐藏任务:不主动在日志中显示,由特定条件触发后才浮现。</summary>
|
||
Hidden,
|
||
}
|
||
|
||
// =========================================================================
|
||
// QuestPrerequisite ── 任务前置条件(统一配置结构)
|
||
// =========================================================================
|
||
|
||
/// <summary>
|
||
/// 任务前置条件统一配置结构,将前置任务依赖和世界状态标志依赖合并为单一可序列化类。
|
||
/// </summary>
|
||
[Serializable]
|
||
public class QuestPrerequisite
|
||
{
|
||
[Tooltip("所有前置任务必须处于 Completed 状态,本任务才能被接取。留空表示无前置任务限制。")]
|
||
public QuestSO[] questDependencies;
|
||
|
||
[Tooltip("世界状态标志前置条件(支持 And / Or 逻辑)。")]
|
||
public FlagCondition flagCondition;
|
||
|
||
/// <summary>此前置结构是否配置了任何条件。</summary>
|
||
public bool HasAny =>
|
||
(questDependencies != null && questDependencies.Length > 0) ||
|
||
(flagCondition.flags != null && flagCondition.flags.Length > 0);
|
||
|
||
/// <summary>
|
||
/// 世界状态标志前置条件,支持 And(全部满足)或 Or(任一满足)逻辑。
|
||
/// </summary>
|
||
[Serializable]
|
||
public struct FlagCondition
|
||
{
|
||
[Tooltip("标志逻辑模式:\n And(默认)= 全部标志均须为 true\n Or = 任意一个标志为 true 即可解锁")]
|
||
public BaseGames.Core.WorldStateFlagLogic logic;
|
||
|
||
[Tooltip("前置世界状态标志 ID 列表。留空表示无标志前置限制。")]
|
||
[BaseGames.Core.WorldStateFlag]
|
||
public string[] flags;
|
||
}
|
||
}
|
||
}
|