using System; using UnityEngine; using BaseGames.Core; using BaseGames.Dialogue; namespace BaseGames.Quest { /// /// 任务定义 SO(架构 22_QuestChallengeModule §2)。 /// 资产路径: Assets/ScriptableObjects/Quest/Quest_{questId}.asset /// [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" + "留空时跳过好感度相关逻辑;与旧字段 giverNpcId 同时有值时以此 SO 为准。")] public NpcSO giverNpc; [System.Obsolete("已废弃,请改用 giverNpc(NpcSO 直接引用)。保留以兼容现有资产序列化。")] [HideInInspector] public string giverNpcId; /// 运行时使用的 NPC ID:giverNpc 优先,回退到旧字段 giverNpcId。 public string GiverNpcId => (giverNpc != null && !string.IsNullOrEmpty(giverNpc.npcId)) ? giverNpc.npcId #pragma warning disable CS0618 : giverNpcId; #pragma warning restore CS0618 [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("任务前置条件(统一配置版)。将前置任务依赖和世界标志依赖合并为单一结构,便于 Inspector 管理。\n" + "如旧版字段(prerequisiteQuests / prerequisiteFlags)已有数据,运行时将自动回退使用旧版字段,无需手动迁移。")] public QuestPrerequisite prerequisites = new QuestPrerequisite(); // ── 旧版前置字段(向后兼容,新配置请改用 prerequisites)──────────────── [HideInInspector] [Tooltip("【已归入 prerequisites.questDependencies,此字段仅用于旧资产兼容】\n" + "所有前置任务必须处于 Completed 状态,本任务才能被接取。\n" + "⚠ 此字段计划在 v2.0 移除,请尽快通过 QuestSOEditor 迁移至 prerequisites。")] public QuestSO[] prerequisiteQuests; [HideInInspector] [Tooltip("【已归入 prerequisites.flagCondition.logic,此字段仅用于旧资产兼容】\n" + "⚠ 此字段计划在 v2.0 移除,请尽快通过 QuestSOEditor 迁移至 prerequisites。")] public BaseGames.Core.WorldStateFlagLogic prerequisiteFlagsLogic = BaseGames.Core.WorldStateFlagLogic.And; [HideInInspector] [Tooltip("【已归入 prerequisites.flagCondition.flags,此字段仅用于旧资产兼容】\n" + "⚠ 此字段计划在 v2.0 移除,请尽快通过 QuestSOEditor 迁移至 prerequisites。")] [BaseGames.Core.WorldStateFlag] public string[] prerequisiteFlags; [Tooltip("接取本任务所需的 NPC 好感度下限(0 = 无限制)。由好感度系统提供实际数值。")] public int minAffinityToAccept; [Header("奖励")] [Tooltip("任务完成时发放的奖励(RewardSO)。留空表示无奖励。\n" + "QuestManager.CompleteQuest() 调用 reward.Apply(rewardTarget),通过 IRewardTarget 接口发放。")] public RewardSO reward; [Header("失败条件(可选)")] [Tooltip("勾选后,failCondition 目标一旦完成,本任务立即失败并触发 EVT_QuestFailed 事件。")] public bool canFail; [Tooltip("失败判定目标。canFail=true 时有效;此目标达成即视为任务失败。")] public QuestObjectiveSO failCondition; [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 s_questIdToPath; private static double s_questIdsCacheTime = -10.0; private static System.Collections.Generic.Dictionary 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(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(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; } ValidateBranchDialogueKeys(); ValidateObjectiveIds(); ValidatePrerequisiteCycles(); ValidateBranchCycles(); } private void ValidateObjectiveIds() { if (objectives == null || objectives.Length == 0) return; var seen = new System.Collections.Generic.HashSet(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); } } /// /// 检测前置任务链是否形成循环依赖(如 A 前置 B、B 前置 A)。 /// 循环会导致两个任务互相锁定,运行时无法被接取,属于配置错误。 /// private void ValidatePrerequisiteCycles() { if (string.IsNullOrEmpty(questId)) return; var visited = new System.Collections.Generic.HashSet(System.StringComparer.Ordinal); visited.Add(questId); #pragma warning disable CS0618 QuestSO[] deps = prerequisites.HasAny ? prerequisites.questDependencies : prerequisiteQuests; #pragma warning restore CS0618 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/prerequisiteQuests 配置。", this); return; } } } /// /// 深度优先遍历前置链,检测是否存在环路。 /// 已访问节点集 在回溯时移除,保证同一链条中不误报平行分支。 /// 超过 32 层时停止递归并输出警告,防止编辑器因超深嵌套卡顿。 /// private static bool HasPrerequisiteCycle(QuestSO quest, System.Collections.Generic.HashSet visited, int depth = 0) { if (depth > 32) { Debug.LogWarning($"[QuestSO] 前置链深度超过 32 层(路径末端:'{quest.name}'),已停止检测。请减少任务链深度。"); return false; } if (string.IsNullOrEmpty(quest.questId)) return false; if (!visited.Add(quest.questId)) return true; // 已在当前路径上 = 环路 #pragma warning disable CS0618 QuestSO[] deps = quest.prerequisites.HasAny ? quest.prerequisites.questDependencies : quest.prerequisiteQuests; #pragma warning restore CS0618 if (deps != null) { foreach (var dep in deps) { if (dep != null && HasPrerequisiteCycle(dep, visited, depth + 1)) return true; } } visited.Remove(quest.questId); // 回溯 return false; } /// /// 检测 branches[].nextQuest 解锁链是否形成循环(如 A 完成解锁 B,B 完成解锁 A)。 /// 循环会导致运行时 UnlockBranches 无限递归设置任务状态,属于配置错误。 /// 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(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; // 一次只报首个问题 } } } /// /// 深度优先遍历 branches[].nextQuest 链,检测是否存在环路(DFS 回溯)。 /// 超过 32 层时停止递归并输出警告,防止编辑器因超深嵌套卡顿。 /// private static bool HasBranchCycle(QuestSO quest, System.Collections.Generic.HashSet visited, int depth = 0) { if (depth > 32) { Debug.LogWarning($"[QuestSO] 分支链深度超过 32 层(路径末端:'{quest.name}'),已停止检测。请减少分支链深度。"); return false; } 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; } private void ValidateBranchDialogueKeys() { if (branches == null || branches.Length == 0) return; foreach (var branch in branches) { if (branch == null) continue; // npcDialogueSequence 是 SO 直接引用,无需字符串校验。 // 旧字段 npcDialogueKey(Obsolete)有值时提示迁移。 #pragma warning disable CS0618 if (!string.IsNullOrEmpty(branch.npcDialogueKey) && branch.npcDialogueSequence == null) { Debug.LogWarning( $"[QuestSO] '{name}' 分支仍使用旧字段 npcDialogueKey='{branch.npcDialogueKey}'," + "请迁移至 npcDialogueSequence(直接拖入 DialogueSequenceSO)。", this); } #pragma warning restore CS0618 } } #endif } [Serializable] public class QuestBranch { [Tooltip("条件任务:该任务已 Completed 时走本分支(留空 = 默认分支,总是满足)。")] public QuestSO conditionQuest; [Tooltip("世界状态标志条件判断逻辑:\n" + " And(默认)= 全部 conditionFlags 均满足才走本分支\n" + " Or = 任意一个 conditionFlag 满足即可走本分支")] public BaseGames.Core.WorldStateFlagLogic conditionFlagsLogic = BaseGames.Core.WorldStateFlagLogic.And; [Tooltip("世界状态标志条件(可选)。按 conditionFlagsLogic 逻辑与 conditionQuest 共同决定分支是否激活。\n" + "标志由 ISaveService.GetFlag(flagId) 查询,通常由 SetFlagAction 或其他系统写入。")] [WorldStateFlag] public string[] conditionFlags; [Tooltip("本分支解锁的后续任务。满足所有条件后,此任务将被设为 Available。")] public QuestSO nextQuest; [Tooltip("完成本任务后触发的 NPC 对话序列(直接引用 DialogueSequenceSO 资产,无需手写 ID)。")] public DialogueSequenceSO npcDialogueSequence; [System.Obsolete("已废弃,请改用 npcDialogueSequence(直接 SO 引用)。保留字段以兼容现有资产序列化。")] [HideInInspector] public string npcDialogueKey; } /// 任务分类,供日志 UI 分区和 DataHub 过滤使用。 public enum QuestCategory { /// 主线任务:必做,推动主剧情进展。 Main, /// 支线任务:可选,丰富世界观与 NPC 关系。 Side, /// 日常/重复任务:可在满足条件后重置。 Daily, /// 隐藏任务:不主动在日志中显示,由特定条件触发后才浮现。 Hidden, } // ========================================================================= // QuestPrerequisite ── 任务前置条件(统一配置结构) // ========================================================================= /// /// 任务前置条件统一配置结构。 /// 将旧版三个独立字段(prerequisiteQuests / prerequisiteFlags / prerequisiteFlagsLogic) /// 合并为单一可序列化类,便于 Inspector 统一管理与代码维护。 /// 运行时通过 判断是否启用新格式;若未配置则自动回退到旧版字段。 /// [Serializable] public class QuestPrerequisite { [Tooltip("所有前置任务必须处于 Completed 状态,本任务才能被接取。留空表示无前置任务限制。")] public QuestSO[] questDependencies; [Tooltip("世界状态标志前置条件(支持 And / Or 逻辑)。")] public FlagCondition flagCondition; /// 此前置结构是否配置了任何条件(用于判断是否启用新格式,回退到旧字段)。 public bool HasAny => (questDependencies != null && questDependencies.Length > 0) || (flagCondition.flags != null && flagCondition.flags.Length > 0); /// /// 世界状态标志前置条件,支持 And(全部满足)或 Or(任一满足)逻辑。 /// [Serializable] public struct FlagCondition { [Tooltip("标志逻辑模式:\n And(默认)= 全部标志均须为 true\n Or = 任意一个标志为 true 即可解锁")] public BaseGames.Core.WorldStateFlagLogic logic; [Tooltip("前置世界状态标志 ID 列表。留空表示无标志前置限制。")] [BaseGames.Core.WorldStateFlag] public string[] flags; } } }