Files
zeling_v2/Assets/_Game/Scripts/Quest/QuestSO.cs
Joywayer 0b28cabba4 feat: Round 52 narrative systems improvements
P1-A: QuestManager.OnLoad Enum.TryParse failure warning (dev builds)
P1-B: SaveData.QuestState ObjectiveCompleted dict; BuildObjectiveCompleted
       helper; OnSave/OnLoad wiring (DataVersion 2→3)
P2-A: Quest start/complete timestamps (_startedAtUtc/_completedAtUtc dicts;
       StartedAtUtc/CompletedAtUtc in SaveData; AcceptQuest/CompleteQuest/
       OnSave/OnLoad wiring)
P2-B: DialogueManager pending queue Queue→List + priority-eviction on full
       (lowest-priority item evicted when higher-priority request arrives)
P2-C: NpcSO.localizationTable field; NpcSOEditor uses npc.localizationTable
       in TryResolveNameKey, PingLocalizationFile, and button label
P3-A: QuestSO.failConditions[] multi-fail array; Obsolete failCondition;
       DispatchEvent updates fail check to any-of-array logic with fallback
P3-B: QuestObjectiveSO.prerequisiteObjectiveId; DispatchEvent gates objective
       event routing behind prerequisite completed check
P3-C: IQuestEventPayload interface + StringQuestPayload struct; QuestObjectiveSO
       typed TryHandleEvent(IQuestEventPayload) overload; DispatchEvent string
       overload delegates to typed IQuestEventPayload overload

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

402 lines
20 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" +
"留空时跳过好感度相关逻辑;与旧字段 giverNpcId 同时有值时以此 SO 为准。")]
public NpcSO giverNpc;
[System.Obsolete("已废弃,请改用 giverNpcNpcSO 直接引用)。保留以兼容现有资产序列化。")]
[HideInInspector]
public string giverNpcId;
/// <summary>运行时使用的 NPC IDgiverNpc 优先,回退到旧字段 giverNpcId。</summary>
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("勾选后failConditions 中任意一个目标完成,本任务立即失败并触发 EVT_QuestFailed 事件。")]
public bool canFail;
[Tooltip("失败判定目标列表任意一个达成即失败。canFail=true 时有效。\n" +
"支持多个失败条件如「BOSS 在限时内未被击败」OR「关键 NPC 死亡」)。")]
public QuestObjectiveSO[] failConditions;
[System.Obsolete("已废弃,请改用 failConditions数组支持多个失败条件。保留以兼容现有资产序列化。")]
[HideInInspector]
[Tooltip("(旧版单一失败条件,已被 failConditions 数组取代。保留以兼容现有资产。)")]
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<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;
}
ValidateBranchDialogueKeys();
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);
}
}
/// <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);
#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;
}
}
}
/// <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.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;
}
/// <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.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 直接引用,无需字符串校验。
// 旧字段 npcDialogueKeyObsolete有值时提示迁移。
#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默认= 全部 conditionFlagEntries 均满足才走本分支\n" +
" Or = 任意一个 conditionFlagEntry 满足即可走本分支")]
public BaseGames.Core.WorldStateFlagLogic conditionFlagsLogic = BaseGames.Core.WorldStateFlagLogic.And;
[Tooltip("世界状态标志条件(支持 invert 取反)。按 conditionFlagsLogic 逻辑与 conditionQuest 共同决定分支是否激活。\n" +
"优先使用此字段;若为空则自动回退到旧版 conditionFlags 以保证兼容性。")]
public BranchFlagEntry[] conditionFlagEntries;
[Tooltip("(旧版兼容字段,已被 conditionFlagEntries 取代。如 conditionFlagEntries 不为空则本字段被忽略。)")]
[HideInInspector]
public string[] conditionFlags;
[Tooltip("本分支解锁的后续任务。满足所有条件后,此任务将被设为 Available。")]
public QuestSO nextQuest;
[Tooltip("完成本任务后触发的 NPC 对话序列(直接引用 DialogueSequenceSO 资产,无需手写 ID。")]
public DialogueSequenceSO npcDialogueSequence;
[System.Obsolete("已废弃,请改用 npcDialogueSequence直接 SO 引用)。保留字段以兼容现有资产序列化。")]
[HideInInspector]
public string npcDialogueKey;
}
/// <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>
/// 任务前置条件统一配置结构。
/// 将旧版三个独立字段prerequisiteQuests / prerequisiteFlags / prerequisiteFlagsLogic
/// 合并为单一可序列化类,便于 Inspector 统一管理与代码维护。
/// 运行时通过 <see cref="HasAny"/> 判断是否启用新格式;若未配置则自动回退到旧版字段。
/// </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;
}
}
}