refactor: Round 53 remove all legacy backward-compatibility code

- QuestSO: remove giverNpcId, prerequisiteQuests/Flags/FlagsLogic, failCondition,
  conditionFlags, npcDialogueKey fields; simplify GiverNpcId property to giverNpc?.npcId;
  clean ValidatePrerequisiteCycles/HasPrerequisiteCycle to use prerequisites.questDependencies;
  remove ValidateBranchDialogueKeys migration warning block; clean QuestPrerequisite doc
- QuestManager: remove OnLoad DataVersion 1/2 migration paths (ProgressCounts, hasNewFormat/
  useNewFormat); remove CheckQuestDepsAndFlags old-field fallback (prerequisiteQuests/Flags);
  remove UnlockBranches conditionFlags fallback; remove DispatchEvent failCondition fallback;
  fix ValidatePrerequisites DFS to scan prerequisites.questDependencies
- SaveData: remove ProgressCounts (Obsolete), ObjectiveIndex (unused), GiverNpcId (never
  written) fields from QuestState; simplify DataVersion doc comment
- QuestSOEditor: replace migration-only editor with minimal DrawDefaultInspector
- QuestModule: update all prerequisiteQuests/conditionFlags/npcDialogueKey/failCondition
  references to canonical new fields; update ValidateBranchFlags check 10
- FlagAuditModule: replace conditionFlags/prerequisiteFlags scans with conditionFlagEntries/
  prerequisites.flagCondition.flags
- NpcSO: remove QuestSO.giverNpcId reference from npcId tooltip
- NpcAffinityEvent/RewardSO: update doc comments to reference giverNpc instead of giverNpcId

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-25 01:00:32 +08:00
parent 0b28cabba4
commit da2948dff8
9 changed files with 97 additions and 365 deletions

View File

@@ -134,31 +134,20 @@ namespace BaseGames.Core.Save
[Serializable]
public class QuestState
{
/// <summary>
/// 此 QuestState 数据格式版本号。
/// 1 = 原始格式ProgressCounts 按索引,已弃用)
/// 2 = Round 24+ 格式ObjectiveProgress 按 objectiveId 键值对)
/// 3 = Round 52+ 格式(新增 ObjectiveCompleted、StartedAtUtc、CompletedAtUtc
/// OnLoad 按此字段显式选择解析路径,杜绝依赖 Count > 0 的隐式推断。
/// </summary>
/// <summary>此 QuestState 数据格式版本号,固定为 3。</summary>
public int DataVersion = 3;
/// <summary>任务运行时状态字符串。有效值Unavailable|Available|Active|Paused|Completed|Failed。
/// OnLoad 通过 Enum.TryParse 解析;无效值将触发开发模式警告并降级为 Unavailable。</summary>
public string Status;
public int ObjectiveIndex;
/// <summary>新格式DataVersion≥2objectiveId → progressCount重排目标顺序后存档不会错位。</summary>
/// <summary>objectiveId → progressCount重排目标顺序后存档不会错位。</summary>
public Dictionary<string, int> ObjectiveProgress = new();
/// <summary>各目标是否已判定完成objectiveId → completed
/// 防止 GetRequiredCount 在版本迭代中变更后,重新计算结果与存档实际状态不一致。DataVersion≥3 写入。</summary>
/// 防止 GetRequiredCount 在版本迭代中变更后,重新计算结果与存档实际状态不一致。</summary>
public Dictionary<string, bool> ObjectiveCompleted = new();
/// <summary>任务接取时间Unix 秒时间戳UTC。0 = 未记录旧存档。DataVersion≥3 写入。</summary>
/// <summary>任务接取时间Unix 秒时间戳UTC。0 = 未记录,跳过。</summary>
public long StartedAtUtc;
/// <summary>任务完成时间Unix 秒时间戳UTC。0 = 未完成或未记录。DataVersion≥3 写入。</summary>
/// <summary>任务完成时间Unix 秒时间戳UTC。0 = 未完成或未记录。</summary>
public long CompletedAtUtc;
/// <summary>旧格式按数组索引DataVersion=1仅用于迁移旧版存档新存档不再写入。已弃用将在后续版本移除。</summary>
[System.Obsolete("旧格式存档兼容字段,仅供 OnLoad DataVersion=1 迁移使用。新存档改用 ObjectiveProgressobjectiveId 键值对)。")]
public List<int> ProgressCounts = new();
public string GiverNpcId;
}
// ─── Achievements ─────────────────────────────────────────────────────────

View File

@@ -17,7 +17,7 @@ namespace BaseGames.Dialogue
public class NpcSO : ScriptableObject
{
[Header("标识")]
[Tooltip("NPC 唯一 ID如 \"NPC_Elder\"。需与 InteractableNPC._npcId、QuestSO.giverNpcId 保持一致。")]
[Tooltip("NPC 唯一 ID如 \"NPC_Elder\"。需与 InteractableNPC._npcId 保持一致。")]
public string npcId;
[Header("显示")]

View File

@@ -155,17 +155,17 @@ namespace BaseGames.Editor.Modules
// 3. 扫描 QuestSO
foreach (var quest in AssetOperations.FindAll<QuestSO>())
{
// branches[i].conditionFlags → 读取
// branches[i].conditionFlagEntries → 读取
if (quest.branches != null)
foreach (var branch in quest.branches)
if (branch.conditionFlags != null)
foreach (var fid in branch.conditionFlags)
if (!string.IsNullOrEmpty(fid))
GetOrCreate(fid).readLocations.Add(($"任务分支条件 [{quest.name}]", quest));
if (branch.conditionFlagEntries != null)
foreach (var entry in branch.conditionFlagEntries)
if (!string.IsNullOrEmpty(entry.flagId))
GetOrCreate(entry.flagId).readLocations.Add(($"任务分支条件 [{quest.name}]", quest));
// prerequisiteFlags → 读取
if (quest.prerequisiteFlags != null)
foreach (var fid in quest.prerequisiteFlags)
// prerequisites.flagCondition.flags → 读取
if (quest.prerequisites.flagCondition.flags != null)
foreach (var fid in quest.prerequisites.flagCondition.flags)
if (!string.IsNullOrEmpty(fid))
GetOrCreate(fid).readLocations.Add(($"任务前置标志 [{quest.name}]", quest));
}

View File

@@ -42,7 +42,7 @@ namespace BaseGames.Editor.Modules
Folder, Prefix,
s =>
{
bool hasPre = s.prerequisiteQuests != null && s.prerequisiteQuests.Length > 0;
bool hasPre = s.prerequisites.questDependencies != null && s.prerequisites.questDependencies.Length > 0;
// 徽章:分类 + 有前置
string catLabel = s.category switch
{
@@ -88,7 +88,7 @@ namespace BaseGames.Editor.Modules
}
_listPane.ExtraFilter = q =>
{
if (filterPrereq && (q.prerequisiteQuests == null || q.prerequisiteQuests.Length == 0)) return false;
if (filterPrereq && (q.prerequisites.questDependencies == null || q.prerequisites.questDependencies.Length == 0)) return false;
if (filterNoObj && (q.objectives != null && q.objectives.Length > 0)) return false;
if (filterCanFail && !q.canFail) return false;
if (filterCategory.HasValue && q.category != filterCategory.Value) return false;
@@ -223,16 +223,16 @@ namespace BaseGames.Editor.Modules
};
SkillModule.AddChip(card, "分类", catDisplay);
// 发布 NPC:优先显示 giverNpc.npcId回退旧 giverNpcId
// 发布 NPC
string giverId = s.GiverNpcId;
if (!string.IsNullOrEmpty(giverId))
SkillModule.AddChip(card, "发布 NPC", giverId);
if (s.prerequisiteQuests != null && s.prerequisiteQuests.Length > 0)
if (s.prerequisites.questDependencies != null && s.prerequisites.questDependencies.Length > 0)
{
// 显示每个前置任务的 questId方便策划一眼看清依赖链
var preIds = new System.Text.StringBuilder();
foreach (var pre in s.prerequisiteQuests)
foreach (var pre in s.prerequisites.questDependencies)
{
if (pre == null) continue;
if (preIds.Length > 0) preIds.Append(", ");
@@ -384,12 +384,8 @@ namespace BaseGames.Editor.Modules
SkillModule.AddChip(row, "条件", condition);
SkillModule.AddChip(row, "后续任务", next);
// 优先显示 SO 引用回退到旧字段Obsolete
string seqName = branch.npcDialogueSequence != null
? branch.npcDialogueSequence.name
#pragma warning disable CS0618
: branch.npcDialogueKey;
#pragma warning restore CS0618
// 优先显示 SO 引用名称
string seqName = branch.npcDialogueSequence != null ? branch.npcDialogueSequence.name : null;
if (!string.IsNullOrEmpty(seqName))
SkillModule.AddChip(row, "对话序列", seqName);
card.Add(row);
@@ -401,7 +397,7 @@ namespace BaseGames.Editor.Modules
/// 构建当前任务的依赖关系可视图(折叠面板形式):
/// - 上方:前置任务链(此任务需要哪些任务先完成)
/// - 下方:后续任务链(此任务完成后可解锁哪些任务)
/// 数据来源allQuests 中所有 QuestSO 的 prerequisiteQuests 引用,无运行时副作用。
/// 数据来源allQuests 中所有 QuestSO 的 prerequisites.questDependencies 引用,无运行时副作用。
/// 节点可点击→选中对应资产EditorGUIUtility.PingObject
/// </summary>
private static VisualElement BuildDependencyGraph(QuestSO s)
@@ -436,10 +432,10 @@ namespace BaseGames.Editor.Modules
var allQuests = s_allQuestCache;
// ── 前置任务(上游)────────────────────────────────────────────────
bool hasPrereqs = s.prerequisiteQuests != null && s.prerequisiteQuests.Length > 0;
bool hasPrereqs = s.prerequisites.questDependencies != null && s.prerequisites.questDependencies.Length > 0;
AddDepSection(container, "▲ 前置任务(需先完成)",
hasPrereqs
? System.Array.ConvertAll(s.prerequisiteQuests, q => (q, "前置"))
? System.Array.ConvertAll(s.prerequisites.questDependencies, q => (q, "前置"))
: null,
hasPrereqs ? null : "(无前置条件,可直接接取)");
@@ -448,8 +444,8 @@ namespace BaseGames.Editor.Modules
foreach (var quest in allQuests)
{
if (quest == null || quest == s) continue;
if (quest.prerequisiteQuests == null) continue;
foreach (var pre in quest.prerequisiteQuests)
if (quest.prerequisites.questDependencies == null) continue;
foreach (var pre in quest.prerequisites.questDependencies)
{
if (pre == s) { downstream.Add((quest, "解锁")); break; }
}
@@ -492,8 +488,8 @@ namespace BaseGames.Editor.Modules
/// </summary>
private static bool HasPrerequisiteCycle(QuestSO origin, QuestSO current, System.Collections.Generic.HashSet<string> visited)
{
if (current?.prerequisiteQuests == null) return false;
foreach (var pre in current.prerequisiteQuests)
if (current?.prerequisites.questDependencies == null) return false;
foreach (var pre in current.prerequisites.questDependencies)
{
if (pre == null || string.IsNullOrEmpty(pre.questId)) continue;
if (pre == origin) return true; // 回到起点,发现循环
@@ -705,13 +701,13 @@ namespace BaseGames.Editor.Modules
/// 1. questId 为空
/// 2. questId 重复
/// 3. objectives 为空(无目标任务)
/// 4. prerequisiteQuests 含空引用
/// 4. prerequisites.questDependencies 含空引用
/// 5. 前置任务循环依赖DFS
/// 6. canFail=true 但 failCondition 为空
/// 7. reward.affinityBonus != 0 但 giverNpcId 为空(好感度会丢失)
/// 6. canFail=true 但 failConditions 为空
/// 7. reward.affinityBonus != 0 但 giverNpc 为空(好感度会丢失)
/// 8. TriggerZone ↔ ReachAreaObjective markerTag 孤儿交叉检测
/// 9. 同任务内 objectiveId 重复(运行时 compositeKey 碰撞)
/// 10. branches[i].conditionFlags 含空白字符串(策划配置遗漏 flag 名)
/// 10. branches[i].conditionFlagEntries 含空 flagId(策划配置遗漏 flag 名)
/// 11. reward.itemIds 含空白字符串或无对应 Collectible 预制件(孤儿奖励 ID
/// 结果在可交互的 QuestValidationResultWindow 中展示,每项问题附"选中"按钮可一键定位资产。
/// </summary>
@@ -778,18 +774,18 @@ namespace BaseGames.Editor.Modules
if (q.objectives == null || q.objectives.Length == 0)
addWarn($"{q.questId}: objectives 为空,任务无任何目标。", q);
if (q.prerequisiteQuests != null)
foreach (var pre in q.prerequisiteQuests)
if (pre == null) { addWarn($"{q.questId}: prerequisiteQuests 含空引用,请清理 Inspector 中的空槽。", q); break; }
if (q.prerequisites.questDependencies != null)
foreach (var pre in q.prerequisites.questDependencies)
if (pre == null) { addWarn($"{q.questId}: prerequisites.questDependencies 含空引用,请清理 Inspector 中的空槽。", q); break; }
if (HasCircularPrerequisite(q, idMap, new HashSet<string>(StringComparer.Ordinal)))
addError($"{q.questId}: 前置任务链存在循环依赖,将导致任务永远无法变为 Available", q);
if (q.canFail && q.failCondition == null)
addWarn($"{q.questId}: canFail=true 但 failCondition 为空,失败条件永不触发。", q);
if (q.canFail && (q.failConditions == null || q.failConditions.Length == 0))
addWarn($"{q.questId}: canFail=true 但 failConditions 为空,失败条件永不触发。", q);
if (q.reward != null && q.reward.affinityBonus != 0 && string.IsNullOrEmpty(q.GiverNpcId))
addWarn($"{q.questId}: reward.affinityBonus={q.reward.affinityBonus} 但 GiverNpcId 为空,好感度增量将丢失。", q);
addWarn($"{q.questId}: reward.affinityBonus={q.reward.affinityBonus} 但 giverNpc 未配置,好感度增量将丢失。", q);
}
}
@@ -839,7 +835,7 @@ namespace BaseGames.Editor.Modules
}
}
// 检查 10branches[i].conditionFlags 含空白字符串
// 检查 10branches[i].conditionFlagEntries 含空 flagId
private static void ValidateBranchFlags(
List<QuestSO> allQuests,
System.Action<string, UnityEngine.Object> addWarn)
@@ -850,10 +846,10 @@ namespace BaseGames.Editor.Modules
for (int bi = 0; bi < q.branches.Length; bi++)
{
var branch = q.branches[bi];
if (branch.conditionFlags == null || branch.conditionFlags.Length == 0) continue;
for (int fi = 0; fi < branch.conditionFlags.Length; fi++)
if (string.IsNullOrWhiteSpace(branch.conditionFlags[fi]))
addWarn($"任务 '{q.questId}' 分支[{bi}].conditionFlags[{fi}] 为空白字符串,运行时将被跳过,请检查是否遗漏标志名。", q);
if (branch.conditionFlagEntries == null || branch.conditionFlagEntries.Length == 0) continue;
for (int fi = 0; fi < branch.conditionFlagEntries.Length; fi++)
if (string.IsNullOrWhiteSpace(branch.conditionFlagEntries[fi].flagId))
addWarn($"任务 '{q.questId}' 分支[{bi}].conditionFlagEntries[{fi}].flagId 为空白字符串,运行时将被跳过,请检查是否遗漏标志名。", q);
}
}
}
@@ -895,8 +891,8 @@ namespace BaseGames.Editor.Modules
HashSet<string> visited)
{
if (!visited.Add(start.questId)) return true;
if (start.prerequisiteQuests == null) return false;
foreach (var pre in start.prerequisiteQuests)
if (start.prerequisites.questDependencies == null) return false;
foreach (var pre in start.prerequisites.questDependencies)
{
if (pre == null || string.IsNullOrEmpty(pre.questId)) continue;
if (!idMap.TryGetValue(pre.questId, out var preQuest)) continue;

View File

@@ -1,118 +1,19 @@
using UnityEditor;
using UnityEngine;
using BaseGames.Quest;
using BaseGames.Core;
namespace BaseGames.Editor.Quest
{
/// <summary>
/// QuestSO 自定义 Inspector。
/// 在检测到旧版前置字段prerequisiteQuests / prerequisiteFlags有数据时
/// 显示迁移提示框和一键迁移按钮,引导策划将数据迁移到 QuestPrerequisite 统一结构。
/// QuestSO 自定义 Inspector(默认布局,使用 DrawDefaultInspector
/// </summary>
[CustomEditor(typeof(QuestSO))]
public class QuestSOEditor : UnityEditor.Editor
{
private bool _showMigrationBox = true;
public override void OnInspectorGUI()
{
serializedObject.Update();
var quest = (QuestSO)target;
// ── 旧版字段迁移提示 ──────────────────────────────────────────────
#pragma warning disable CS0618
bool hasLegacyQuests = quest.prerequisiteQuests != null && quest.prerequisiteQuests.Length > 0;
bool hasLegacyFlags = quest.prerequisiteFlags != null && quest.prerequisiteFlags.Length > 0;
#pragma warning restore CS0618
if (hasLegacyQuests || hasLegacyFlags)
{
_showMigrationBox = EditorGUILayout.BeginFoldoutHeaderGroup(_showMigrationBox, "⚠ 旧版前置字段迁移");
if (_showMigrationBox)
{
EditorGUILayout.HelpBox(
"检测到旧版前置字段有数据:\n" +
(hasLegacyQuests ? $" • prerequisiteQuests{quest.prerequisiteQuests.Length} 项\n" : "") +
(hasLegacyFlags ? $" • prerequisiteFlags{quest.prerequisiteFlags.Length} 项\n" : "") +
"\n新版 'prerequisites'QuestPrerequisite字段已支持更完整的前置配置。\n" +
"点击下方按钮可将旧版数据自动迁移至新字段,迁移后旧字段将被清空。\n" +
"迁移操作可撤销Ctrl+Z。",
MessageType.Warning);
bool hasNewData = quest.prerequisites.HasAny;
if (hasNewData)
EditorGUILayout.HelpBox(
"新版 prerequisites 字段已有数据。点击迁移将与旧版数据合并(去重),不会覆盖现有配置。",
MessageType.Info);
if (GUILayout.Button("一键迁移旧版前置字段 → prerequisites"))
{
MigrateLegacyPrerequisites(quest);
}
}
EditorGUILayout.EndFoldoutHeaderGroup();
EditorGUILayout.Space(4);
}
// ── 默认 Inspector ────────────────────────────────────────────────
DrawDefaultInspector();
serializedObject.ApplyModifiedProperties();
}
private static void MigrateLegacyPrerequisites(QuestSO quest)
{
Undo.RecordObject(quest, "迁移 QuestSO 旧版前置字段");
#pragma warning disable CS0618
int legacyQuestCount = quest.prerequisiteQuests?.Length ?? 0;
int legacyFlagCount = quest.prerequisiteFlags?.Length ?? 0;
// 迁移 prerequisiteQuests → prerequisites.questDependencies合并去重
if (quest.prerequisiteQuests != null && quest.prerequisiteQuests.Length > 0)
{
var existing = quest.prerequisites.questDependencies ?? System.Array.Empty<QuestSO>();
var merged = new System.Collections.Generic.HashSet<QuestSO>(existing);
foreach (var q in quest.prerequisiteQuests)
if (q != null) merged.Add(q);
quest.prerequisites.questDependencies = new QuestSO[merged.Count];
merged.CopyTo(quest.prerequisites.questDependencies);
quest.prerequisiteQuests = System.Array.Empty<QuestSO>();
}
// 迁移 prerequisiteFlags → prerequisites.flagCondition合并去重
if (quest.prerequisiteFlags != null && quest.prerequisiteFlags.Length > 0)
{
var existing = quest.prerequisites.flagCondition.flags ?? System.Array.Empty<string>();
var merged = new System.Collections.Generic.HashSet<string>(
existing, System.StringComparer.Ordinal);
foreach (var f in quest.prerequisiteFlags)
if (!string.IsNullOrEmpty(f)) merged.Add(f);
quest.prerequisites.flagCondition.flags = new string[merged.Count];
merged.CopyTo(quest.prerequisites.flagCondition.flags);
// 迁移逻辑模式(旧字段覆盖新字段,以旧配置为准)
quest.prerequisites.flagCondition.logic = quest.prerequisiteFlagsLogic;
quest.prerequisiteFlags = System.Array.Empty<string>();
quest.prerequisiteFlagsLogic = WorldStateFlagLogic.And;
}
#pragma warning restore CS0618
EditorUtility.SetDirty(quest);
AssetDatabase.SaveAssets();
Debug.Log($"[QuestSOEditor] '{quest.name}' 旧版前置字段迁移完成(任务:{legacyQuestCount} 项,标志:{legacyFlagCount} 项)。", quest);
EditorUtility.DisplayDialog(
"迁移完成",
$"任务 \"{quest.name}\" 旧版前置字段已成功迁移:\n\n" +
$" 前置任务:{legacyQuestCount} 项 → prerequisites.questDependencies\n" +
$" 前置标志:{legacyFlagCount} 项 → prerequisites.flagCondition.flags\n\n" +
"旧版字段已清空。操作可通过 Ctrl+Z 撤销。",
"确定");
}
}
}

View File

@@ -10,7 +10,8 @@ namespace BaseGames.Quest
[System.Serializable]
public struct NpcAffinityEvent
{
/// <summary>发生好感度变化的 NPC ID与 QuestSO.giverNpcId 保持一致)。</summary>
/// <summary>
/// NPC 好感度变化的 NPC ID与 QuestSO.giverNpc.npcId 保持一致)。</summary>
public string npcId;
/// <summary>好感度变化量(正值=增加,负值=减少)。</summary>
public int delta;

View File

@@ -438,19 +438,14 @@ namespace BaseGames.Quest
if (!conditionMet) continue;
// 世界状态标志条件And/Or 由 conditionFlagsLogic 决定)
// 优先用新版 conditionFlagEntries支持 invert/NOT 取反),若为空则回退到旧版 conditionFlags
// saveService 未注入时降级:跳过标志检查,仅由 conditionQuest 决定分支
bool hasFlagEntries = branch.conditionFlagEntries != null && branch.conditionFlagEntries.Length > 0;
bool hasLegacyFlags = branch.conditionFlags != null && branch.conditionFlags.Length > 0;
bool hasFlagConds = hasFlagEntries || hasLegacyFlags;
if (hasFlagConds && saveService != null)
if (hasFlagEntries && saveService != null)
{
if (branch.conditionFlagsLogic == BaseGames.Core.WorldStateFlagLogic.Or)
{
conditionMet = false;
if (hasFlagEntries)
{
foreach (var entry in branch.conditionFlagEntries)
{
if (string.IsNullOrEmpty(entry.flagId)) continue;
@@ -459,19 +454,8 @@ namespace BaseGames.Quest
}
}
else
{
foreach (var flag in branch.conditionFlags)
{
if (!string.IsNullOrEmpty(flag) && saveService.GetFlag(flag))
{ conditionMet = true; break; }
}
}
}
else
{
// AND默认全部标志均须满足支持 invert 取反)
if (hasFlagEntries)
{
foreach (var entry in branch.conditionFlagEntries)
{
if (string.IsNullOrEmpty(entry.flagId)) continue;
@@ -479,18 +463,9 @@ namespace BaseGames.Quest
if (entry.invert ? raw : !raw) { conditionMet = false; break; }
}
}
else
{
foreach (var flag in branch.conditionFlags)
{
if (string.IsNullOrEmpty(flag)) continue;
if (!saveService.GetFlag(flag)) { conditionMet = false; break; }
}
}
}
}
#if UNITY_EDITOR || DEVELOPMENT_BUILD
else if (hasFlagConds && saveService == null)
else if (hasFlagEntries && saveService == null)
{
Debug.LogWarning(
$"[QuestManager] 任务 '{questId}' 分支配置了标志条件,但 ISaveService 未注册," +
@@ -700,14 +675,8 @@ namespace BaseGames.Quest
var quest = GetQuestSO(id);
if (quest?.objectives == null) continue;
bool hasNewFormat = saved.ObjectiveProgress != null && saved.ObjectiveProgress.Count > 0;
// DataVersion >= 2新格式objectiveId 键值对DataVersion <= 1 或遗留存档:旧格式(按索引)
// Count > 0 作为无 DataVersion 字段时的兼容兜底
bool useNewFormat = saved.DataVersion >= 2 || hasNewFormat;
if (useNewFormat && saved.ObjectiveProgress != null)
if (saved.ObjectiveProgress != null)
{
// 新格式objectiveId → count重排顺序后仍可正确恢复
foreach (var obj in quest.objectives)
{
if (obj == null) continue;
@@ -716,29 +685,12 @@ namespace BaseGames.Quest
if (!_objectiveStates.TryGetValue(compositeKey, out var os))
os = _objectiveStates[compositeKey] = new QuestObjectiveState();
os.progressCount = count;
// DataVersion >= 3从存档恢复 completed 标志(防止 GetRequiredCount 变更后判定漂移)
if (saved.ObjectiveCompleted != null &&
saved.ObjectiveCompleted.TryGetValue(obj.objectiveId, out bool done))
os.completed = done;
}
}
else if (saved.ProgressCounts != null
#pragma warning disable CS0618 // ProgressCounts 弃用字段:仅在此处读取用于旧存档迁移,不再写入
&& saved.ProgressCounts.Count > 0)
{
// 旧格式兼容(按数组索引):迁移旧存档用,不再写入新存档
for (int i = 0; i < quest.objectives.Length && i < saved.ProgressCounts.Count; i++)
{
var obj = quest.objectives[i];
if (obj == null) continue;
string compositeKey = GetCompositeKey(id, obj.objectiveId);
if (!_objectiveStates.TryGetValue(compositeKey, out var os))
os = _objectiveStates[compositeKey] = new QuestObjectiveState();
os.progressCount = saved.ProgressCounts[i];
}
}
#pragma warning restore CS0618
// DataVersion >= 3恢复任务开始 / 完成时间戳0 = 旧存档未记录,跳过)
if (saved.StartedAtUtc != 0) _startedAtUtc[id] = saved.StartedAtUtc;
if (saved.CompletedAtUtc != 0) _completedAtUtc[id] = saved.CompletedAtUtc;
}
@@ -775,7 +727,7 @@ namespace BaseGames.Quest
/// <summary>
/// 初始化(或修正)所有任务的 Available/Unavailable 状态。
/// 在 Awake冷启动和 OnLoad存档恢复后调用。
/// OnLoad 后 ISaveService 已就绪,会重新评估 prerequisiteFlags
/// OnLoad 后 ISaveService 已就绪,会重新评估 prerequisites.flagCondition.flags
/// 修正 Awake 期间因服务未就绪而被跳过的标志检查。
/// Active/Completed/Failed 状态来自存档,不重置。
/// </summary>
@@ -794,7 +746,7 @@ namespace BaseGames.Quest
// _affinityInitialized 为 true 说明是 OnLoad 后调用Awake 期间不打此日志
bool isNewToSave = !_questStates.ContainsKey(q.questId) && _affinityInitialized;
#endif
// Available/Unavailable 均重新评估,确保 prerequisiteFlags 变更后状态正确
// Available/Unavailable 均重新评估,确保 prerequisites.flagCondition.flags 变更后状态正确
_questStates[q.questId] = MeetsPrerequisites(q) ? QuestStateEnum.Available : QuestStateEnum.Unavailable;
#if UNITY_EDITOR || DEVELOPMENT_BUILD
if (isNewToSave)
@@ -824,8 +776,6 @@ namespace BaseGames.Quest
{
if (quest == null) return new QuestLockInfo { Reason = QuestLockReason.NotFound };
if (quest.prerequisites.HasAny)
{
if (quest.prerequisites.questDependencies != null)
foreach (var dep in quest.prerequisites.questDependencies)
{
@@ -849,40 +799,6 @@ namespace BaseGames.Quest
if (svc != null && !EvaluateFlagPrerequisites(fc.flags, fc.logic, svc))
return new QuestLockInfo { Reason = QuestLockReason.FlagConditionNotMet };
}
}
else
{
// 旧版字段回退(兼容现有资产)
#pragma warning disable CS0618
if (quest.prerequisiteQuests != null)
foreach (var pre in quest.prerequisiteQuests)
{
if (pre == null) continue;
if (string.IsNullOrEmpty(pre.questId))
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
Debug.LogWarning($"[QuestManager] 任务 '{quest.questId}' 的 prerequisiteQuests 含 questId 为空的条目,已跳过该前置条件。");
#endif
continue;
}
if (GetState(pre.questId) != QuestStateEnum.Completed)
return new QuestLockInfo { Reason = QuestLockReason.RequiresQuest, Param = pre.questId };
}
if (quest.prerequisiteFlags != null && quest.prerequisiteFlags.Length > 0)
{
var svc = BaseGames.Core.ServiceLocator.GetOrDefault<ISaveService>();
if (svc != null)
{
if (!EvaluateFlagPrerequisites(quest.prerequisiteFlags, quest.prerequisiteFlagsLogic, svc))
return new QuestLockInfo { Reason = QuestLockReason.FlagConditionNotMet };
}
#if UNITY_EDITOR || DEVELOPMENT_BUILD
else Debug.LogWarning($"[QuestManager] 任务 '{quest.questId}' 的 prerequisiteFlags 需要 ISaveService但服务未注册标志检查已跳过。");
#endif
}
#pragma warning restore CS0618
}
return new QuestLockInfo { Reason = QuestLockReason.None };
}
@@ -1065,22 +981,14 @@ namespace BaseGames.Quest
// 设计意图:暂停期间目标冻结,失败条件也不判定,恢复后再继续检查。
if (quest.canFail)
{
// P3-A多失败条件支持——failConditions 数组中任意一个达成即失败
bool triggered = false;
if (quest.failConditions != null && quest.failConditions.Length > 0)
if (quest.failConditions != null)
{
foreach (var fc in quest.failConditions)
{
if (fc != null && CheckObjective(qid, fc)) { triggered = true; break; }
}
}
else
{
// 向后兼容旧版单一 failCondition 字段Obsolete将在后续版本移除
#pragma warning disable CS0618
triggered = quest.failCondition != null && CheckObjective(qid, quest.failCondition);
#pragma warning restore CS0618
}
if (triggered)
{
toFail ??= new List<string>();
@@ -1181,7 +1089,7 @@ namespace BaseGames.Quest
#if UNITY_EDITOR || DEVELOPMENT_BUILD
/// <summary>
/// 通过 DFS 后序遍历检测 prerequisiteQuests 中是否存在循环引用。
/// 通过 DFS 后序遍历检测 prerequisites.questDependencies 中是否存在循环引用。
/// 在编辑器 OnValidate 及开发构建 Awake 时调用,发现问题立即打 LogError。
/// </summary>
[UnityEngine.ContextMenu("校验前置任务循环引用")]
@@ -1219,10 +1127,10 @@ namespace BaseGames.Quest
color[startId] = 1;
path.Add(startId);
var prereqs = index[startId].prerequisiteQuests;
if (prereqs != null)
var prereqDeps = index[startId].prerequisites.questDependencies;
if (prereqDeps != null)
{
foreach (var pre in prereqs)
foreach (var pre in prereqDeps)
{
if (pre == null || string.IsNullOrEmpty(pre.questId)) continue;
if (HasCycle(pre.questId, path)) return true;

View File

@@ -16,21 +16,13 @@ namespace BaseGames.Quest
[Tooltip("任务唯一 ID如 \"Quest_FindMushroom\"。运行时由 QuestManager 以此为键索引,必须全局唯一。")]
public string questId;
[Tooltip("发布/完成该任务的 NPC直接引用 NpcSO 资产,推荐)。\n" +
[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
/// <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;
@@ -52,28 +44,9 @@ namespace BaseGames.Quest
public QuestObjectiveSO[] objectives;
[Header("前置条件")]
[Tooltip("任务前置条件(统一配置版)。将前置任务依赖世界标志依赖合并为单一结构,便于 Inspector 管理。\n" +
"如旧版字段prerequisiteQuests / prerequisiteFlags已有数据运行时将自动回退使用旧版字段无需手动迁移。")]
[Tooltip("任务前置条件(前置任务依赖 + 世界标志依赖)。留空表示无前置限制。")]
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;
@@ -88,10 +61,6 @@ namespace BaseGames.Quest
[Tooltip("失败判定目标列表任意一个达成即失败。canFail=true 时有效。\n" +
"支持多个失败条件如「BOSS 在限时内未被击败」OR「关键 NPC 死亡」)。")]
public QuestObjectiveSO[] failConditions;
[System.Obsolete("已废弃,请改用 failConditions数组支持多个失败条件。保留以兼容现有资产序列化。")]
[HideInInspector]
[Tooltip("(旧版单一失败条件,已被 failConditions 数组取代。保留以兼容现有资产。)")]
public QuestObjectiveSO failCondition;
[Header("接取/完成对话")]
[Tooltip("玩家接取任务时自动触发的 NPC 对话序列(如 NPC 委托台词)。\n" +
@@ -182,10 +151,7 @@ namespace BaseGames.Quest
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
QuestSO[] deps = prerequisites.questDependencies;
if (deps == null) return;
foreach (var dep in deps)
@@ -196,7 +162,7 @@ namespace BaseGames.Quest
Debug.LogError(
$"[QuestSO] '{name}'questId='{questId}')的前置任务链存在循环依赖!" +
$"前置任务 '{dep.name}' 最终指回自身或已访问任务," +
"运行时将导致任务无法被接取。请检查 prerequisites/prerequisiteQuests 配置。", this);
"运行时将导致任务无法被接取。请检查 prerequisites 配置。", this);
return;
}
}
@@ -218,9 +184,7 @@ namespace BaseGames.Quest
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
QuestSO[] deps = quest.prerequisites.questDependencies;
if (deps != null)
{
@@ -288,23 +252,7 @@ namespace BaseGames.Quest
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
}
// npcDialogueSequence 是 SO 直接引用;旧字符串字段已移除。
}
#endif
}
@@ -318,20 +266,12 @@ namespace BaseGames.Quest
" And默认= 全部 conditionFlagEntries 均满足才走本分支\n" +
" Or = 任意一个 conditionFlagEntry 满足即可走本分支")]
public BaseGames.Core.WorldStateFlagLogic conditionFlagsLogic = BaseGames.Core.WorldStateFlagLogic.And;
[Tooltip("世界状态标志条件(支持 invert 取反)。按 conditionFlagsLogic 逻辑与 conditionQuest 共同决定分支是否激活。\n" +
"优先使用此字段;若为空则自动回退到旧版 conditionFlags 以保证兼容性。")]
[Tooltip("世界状态标志条件(支持 invert 取反)。按 conditionFlagsLogic 逻辑与 conditionQuest 共同决定分支是否激活。")]
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>
@@ -365,10 +305,7 @@ namespace BaseGames.Quest
// =========================================================================
/// <summary>
/// 任务前置条件统一配置结构。
/// 将旧版三个独立字段prerequisiteQuests / prerequisiteFlags / prerequisiteFlagsLogic
/// 合并为单一可序列化类,便于 Inspector 统一管理与代码维护。
/// 运行时通过 <see cref="HasAny"/> 判断是否启用新格式;若未配置则自动回退到旧版字段。
/// 任务前置条件统一配置结构,将前置任务依赖和世界状态标志依赖合并为单一可序列化类
/// </summary>
[Serializable]
public class QuestPrerequisite
@@ -379,7 +316,7 @@ namespace BaseGames.Quest
[Tooltip("世界状态标志前置条件(支持 And / Or 逻辑)。")]
public FlagCondition flagCondition;
/// <summary>此前置结构是否配置了任何条件(用于判断是否启用新格式,回退到旧字段)。</summary>
/// <summary>此前置结构是否配置了任何条件。</summary>
public bool HasAny =>
(questDependencies != null && questDependencies.Length > 0) ||
(flagCondition.flags != null && flagCondition.flags.Length > 0);

View File

@@ -25,7 +25,7 @@ namespace BaseGames.Quest
public string[] itemIds;
[Header("NPC 关系")]
[Tooltip("完成任务后对 giverNpcIdQuestSO 中配置)的好感度增量。\n" +
[Tooltip("完成任务后对发布 NPCQuestSO.giverNpc)的好感度增量。\n" +
"正值=好感增加,负值=好感降低。0 = 不影响好感度。\n" +
"增量以强类型 NpcAffinityEventnpcId + delta + newTotal广播至 EVT_NpcAffinityChanged\n" +
"并持久化到 SaveData.World.NpcRelations。接收方无需字符串解析。")]