Files
zeling_v2/Assets/_Game/Scripts/Quest/QuestSO.cs
Joywayer 8e88fc42e9 fix: Round 55 递归硬中止、存档加载缓存刷新、好感度空值防护、选项穿透延迟、分支对话去重
- QuestSO.HasPrerequisiteCycle/HasBranchCycle: depth>32 改为 LogError+return true 硬中止,防止栈溢出
- DialogueSequenceSO.HasChoiceCycle: 新增 depth 参数及 >32 硬中止,同时更新递归调用传 depth+1
- IQuestEventSource: 新增 OnAfterSaveLoaded 事件接口,供存档加载后统一刷新缓存
- QuestManager.OnLoad: 末尾触发 OnAfterSaveLoaded,确保所有缓存组件收到通知
- QuestGiver: 订阅 OnAfterSaveLoaded 设 _cacheDirty,存档恢复后 NPC 交互提示始终最新
- QuestManager.ApplyAffinity: 新增 giverNpc null 显式 LogWarning、maxAffinity<0 LogError 防护
- DialogueManager: 选项穿透防护改为预创建 WaitForSeconds(0.15f),替代 yield return null
- QuestManager.UnlockBranches: 多分支同时满足时只播首个有对话的分支,防止重复播放

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-25 07:37:03 +08:00

344 lines
17 KiB
C#
Raw 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.
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;
}
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 完成解锁 BB 完成解锁 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;
}
}
}