Files
zeling_v2/Assets/_Game/Scripts/Editor/Modules/QuestModule.cs
Joywayer da2948dff8 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>
2026-05-25 01:00:32 +08:00

1005 lines
48 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 System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
using BaseGames.Quest;
using BaseGames.Editor.Shared;
namespace BaseGames.Editor.Modules
{
/// <summary>
/// DataHub 任务模块 —— 管理 QuestSO 资产。
/// </summary>
public class QuestModule : IDataModule, IDataModuleOrdered
{
private const string Folder = "Assets/_Game/Data/Quest";
private const string Prefix = "Quest_";
public string ModuleId => "quest";
public string DisplayName => "任务";
public string IconName => "d_UnityEditor.InspectorWindow";
public int DisplayOrder => 110;
private SoListPane<QuestSO> _listPane;
private DetailHeader _header;
private QuestSO _selected;
// playModeStateChanged 订阅的字段引用,便于在重建 ActionBar 时退订旧订阅,避免内存泄漏
private System.Action<UnityEditor.PlayModeStateChange> _playModeHandler;
// 依赖关系图中 FindAll<QuestSO>() 的静态缓存,同一编辑器会话内复用,避免重复扫描磁盘
private static QuestSO[] s_allQuestCache;
private static double s_allQuestCacheTime;
private const double k_AllQuestCacheTtl = 5.0; // 秒;超时后下次打开 foldout 时刷新
public void Initialize()
{
_listPane = new SoListPane<QuestSO>(
Folder, Prefix,
s =>
{
bool hasPre = s.prerequisites.questDependencies != null && s.prerequisites.questDependencies.Length > 0;
// 徽章:分类 + 有前置
string catLabel = s.category switch
{
QuestCategory.Main => "主线",
QuestCategory.Daily => "日常",
QuestCategory.Hidden => "隐藏",
_ => null, // Side 不显示(默认值,减少视觉噪声)
};
if (catLabel != null) return catLabel;
return hasPre ? "有前置" : null;
});
// 扩展搜索questId + displayNameKey + category
_listPane.GetExtraSearchText = q =>
$"{q.questId} {q.displayNameKey} {q.category}";
}
public void BuildListPane(VisualElement container, Action<UnityEngine.Object> onSelected)
{
_listPane.SelectionChanged = sel =>
{
_selected = sel;
onSelected?.Invoke(sel);
};
// ── 快速过滤标签行 ─────────────────────────────────────────────
var filterRow = new VisualElement();
filterRow.style.flexDirection = FlexDirection.Row;
filterRow.style.flexWrap = Wrap.Wrap;
filterRow.style.paddingLeft = 6;
filterRow.style.paddingRight = 6;
filterRow.style.paddingBottom = 3;
container.Add(filterRow);
bool filterPrereq = false, filterNoObj = false, filterCanFail = false;
QuestCategory? filterCategory = null;
void RebuildFilter()
{
if (!filterPrereq && !filterNoObj && !filterCanFail && filterCategory == null)
{
_listPane.ExtraFilter = null;
return;
}
_listPane.ExtraFilter = q =>
{
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;
return true;
};
}
filterRow.Add(MakeFilterChip("主线", v => { filterCategory = v ? QuestCategory.Main : (QuestCategory?)null; RebuildFilter(); }));
filterRow.Add(MakeFilterChip("支线", v => { filterCategory = v ? QuestCategory.Side : (QuestCategory?)null; RebuildFilter(); }));
filterRow.Add(MakeFilterChip("日常", v => { filterCategory = v ? QuestCategory.Daily : (QuestCategory?)null; RebuildFilter(); }));
filterRow.Add(MakeFilterChip("隐藏", v => { filterCategory = v ? QuestCategory.Hidden : (QuestCategory?)null; RebuildFilter(); }));
// 分隔
var sep = new Label("|");
sep.style.opacity = 0.3f;
sep.style.marginLeft = 2;
sep.style.marginRight = 2;
filterRow.Add(sep);
filterRow.Add(MakeFilterChip("有前置", v => { filterPrereq = v; RebuildFilter(); }));
filterRow.Add(MakeFilterChip("无目标", v => { filterNoObj = v; RebuildFilter(); }));
filterRow.Add(MakeFilterChip("可失败", v => { filterCanFail = v; RebuildFilter(); }));
container.Add(_listPane);
_listPane.Refresh();
}
internal static VisualElement MakeFilterChip(string label, System.Action<bool> onToggle)
{
bool active = false;
var chip = new Label(label);
chip.style.fontSize = 10;
chip.style.paddingLeft = 6;
chip.style.paddingRight = 6;
chip.style.paddingTop = 2;
chip.style.paddingBottom = 2;
chip.style.marginRight = 4;
chip.style.marginBottom = 2;
chip.style.borderTopLeftRadius = 8;
chip.style.borderTopRightRadius = 8;
chip.style.borderBottomLeftRadius = 8;
chip.style.borderBottomRightRadius = 8;
chip.style.borderTopWidth = 1;
chip.style.borderRightWidth = 1;
chip.style.borderBottomWidth = 1;
chip.style.borderLeftWidth = 1;
chip.style.borderTopColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.4f));
chip.style.borderRightColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.4f));
chip.style.borderBottomColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.4f));
chip.style.borderLeftColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.4f));
chip.style.opacity = 0.6f;
void SetActive(bool on)
{
active = on;
chip.style.opacity = on ? 1f : 0.6f;
chip.style.backgroundColor = on
? new StyleColor(new Color(0.3f, 0.6f, 1f, 0.25f))
: StyleKeyword.None;
onToggle(on);
}
chip.RegisterCallback<ClickEvent>(_ => SetActive(!active));
return chip;
}
public void BuildDetailPane(VisualElement container, UnityEngine.Object selected)
{
_selected = selected as QuestSO;
_header = new DetailHeader();
_header.SetAsset(_selected);
_header.RenameRequested += OnRenameRequested;
container.Add(_header);
if (_selected == null) return;
container.Add(BuildInfoCard(_selected));
container.Add(BuildObjectivesList(_selected));
if (_selected.branches != null && _selected.branches.Length > 0)
container.Add(BuildBranchesCard(_selected));
container.Add(BuildDependencyGraph(_selected));
container.Add(BuildActionBar(_selected));
container.Add(SkillModule.MakeDivider());
container.Add(new InspectorElement(_selected));
}
public void OnActivated() => _listPane?.Refresh();
// ── 内部 ─────────────────────────────────────────────────────────────
private void OnRenameRequested(string newName)
{
if (_selected == null) return;
var (ok, err) = AssetOperations.Rename(_selected, newName);
if (!ok) EditorUtility.DisplayDialog("重命名失败", err, "确定");
else { _header.SetAsset(_selected); _listPane.Invalidate(); }
}
private static VisualElement BuildInfoCard(QuestSO s)
{
var card = SkillModule.MakeCard();
int objCount = s.objectives != null ? s.objectives.Length : 0;
SkillModule.AddChip(card, "ID", string.IsNullOrEmpty(s.questId) ? "(未设置)" : s.questId);
// 名称:优先显示本地化实际文本,回退到 Key 本身(与 ActorModule 保持一致)
string nameDisplay;
if (string.IsNullOrEmpty(s.displayNameKey))
{
nameDisplay = "(未设置)";
}
else
{
var resolved = BaseGames.Localization.LocalizationManager.GetEditorPreview(s.displayNameKey, "Quest");
nameDisplay = resolved != null ? resolved : s.displayNameKey + " ⚠ [缺少本地化]";
}
SkillModule.AddChip(card, "名称", nameDisplay);
if (!string.IsNullOrEmpty(s.displayNameKey))
SkillModule.AddChip(card, "名称 Key", s.displayNameKey);
if (!string.IsNullOrEmpty(s.descriptionKey))
SkillModule.AddChip(card, "描述 Key", s.descriptionKey);
SkillModule.AddChip(card, "目标数", objCount.ToString());
// 分类标签
string catDisplay = s.category switch
{
QuestCategory.Main => "主线",
QuestCategory.Side => "支线",
QuestCategory.Daily => "日常",
QuestCategory.Hidden => "隐藏",
_ => s.category.ToString(),
};
SkillModule.AddChip(card, "分类", catDisplay);
// 发布 NPC
string giverId = s.GiverNpcId;
if (!string.IsNullOrEmpty(giverId))
SkillModule.AddChip(card, "发布 NPC", giverId);
if (s.prerequisites.questDependencies != null && s.prerequisites.questDependencies.Length > 0)
{
// 显示每个前置任务的 questId方便策划一眼看清依赖链
var preIds = new System.Text.StringBuilder();
foreach (var pre in s.prerequisites.questDependencies)
{
if (pre == null) continue;
if (preIds.Length > 0) preIds.Append(", ");
preIds.Append(string.IsNullOrEmpty(pre.questId) ? pre.name : pre.questId);
}
if (preIds.Length > 0)
SkillModule.AddChip(card, "前置任务", preIds.ToString());
}
if (s.minAffinityToAccept > 0)
SkillModule.AddChip(card, "好感门槛", s.minAffinityToAccept.ToString());
if (s.canFail)
SkillModule.AddChip(card, "可失败", "✓");
if (s.reward != null)
{
SkillModule.AddChip(card, "奖励资产", s.reward.name);
// 展示奖励具体内容,方便策划确认配置
var rewardDetail = new System.Text.StringBuilder();
if (s.reward.lingZhu > 0) rewardDetail.Append($"灵珠×{s.reward.lingZhu} ");
if (s.reward.soulBonus > 0) rewardDetail.Append($"灵魂槽+{s.reward.soulBonus} ");
if (s.reward.itemIds != null && s.reward.itemIds.Length > 0)
rewardDetail.Append($"物品×{s.reward.itemIds.Length} ");
if (s.reward.affinityBonus != 0)
rewardDetail.Append($"好感{(s.reward.affinityBonus > 0 ? "+" : "")}{s.reward.affinityBonus} ");
if (s.reward.unlocksAbility)
rewardDetail.Append("能力解锁 ");
if (!string.IsNullOrEmpty(s.reward.unlockDialogueKey))
rewardDetail.Append("台词解锁 ");
string detail = rewardDetail.ToString().TrimEnd();
if (!string.IsNullOrEmpty(detail))
SkillModule.AddChip(card, "奖励内容", detail);
}
return card;
}
private static VisualElement BuildObjectivesList(QuestSO s)
{
var section = new VisualElement();
section.style.paddingLeft = 12;
section.style.paddingRight = 12;
section.style.paddingTop = 6;
section.style.paddingBottom = 6;
var title = new Label("目标列表");
title.style.fontSize = 11;
title.style.opacity = 0.55f;
title.style.marginBottom = 4;
title.style.unityFontStyleAndWeight = FontStyle.Bold;
section.Add(title);
if (s.objectives == null || s.objectives.Length == 0)
{
var empty = new Label("(无目标)");
empty.style.opacity = 0.4f;
empty.style.fontSize = 11;
section.Add(empty);
return section;
}
for (int i = 0; i < s.objectives.Length; i++)
{
var obj = s.objectives[i];
if (obj == null) continue;
var row = new VisualElement();
row.style.flexDirection = FlexDirection.Row;
row.style.alignItems = Align.Center;
row.style.marginBottom = 3;
// 序号
var idx = new Label($"{i + 1}.");
idx.style.fontSize = 11;
idx.style.opacity = 0.5f;
idx.style.marginRight = 4;
idx.style.width = 16;
idx.style.flexShrink = 0;
row.Add(idx);
// 类型徽章
string badge = obj.BadgeLabel;
var badgeLbl = new Label(badge);
badgeLbl.style.fontSize = 10;
badgeLbl.style.opacity = 0.7f;
badgeLbl.style.marginRight = 6;
badgeLbl.style.flexShrink = 0;
badgeLbl.style.unityFontStyleAndWeight = FontStyle.Bold;
row.Add(badgeLbl);
// ID
string id = string.IsNullOrEmpty(obj.objectiveId) ? obj.name : obj.objectiveId;
var idLbl = new Label(id);
idLbl.style.fontSize = 11;
idLbl.style.flexGrow = 1;
row.Add(idLbl);
// 可选标记
if (obj.IsOptional)
{
var opt = new Label("[可选]");
opt.style.fontSize = 10;
opt.style.opacity = 0.5f;
opt.style.marginLeft = 4;
row.Add(opt);
}
section.Add(row);
// 目标描述(本地化预览,灰色小字,显示策划填写的实际内容)
if (!string.IsNullOrEmpty(obj.displayTextKey))
{
var resolved = BaseGames.Localization.LocalizationManager.GetEditorPreview(obj.displayTextKey, "Quest");
bool l10nMissing = resolved == null;
string descText = l10nMissing ? obj.displayTextKey + " ⚠ [缺少本地化]" : resolved;
var desc = new Label(descText);
desc.style.fontSize = 10;
desc.style.opacity = l10nMissing ? 1.0f : 0.55f;
desc.style.color = l10nMissing
? new StyleColor(new Color(1f, 0.6f, 0.1f))
: new StyleColor(StyleKeyword.Null);
desc.style.paddingLeft = 26;
desc.style.marginBottom = 2;
section.Add(desc);
}
}
return section;
}
private static VisualElement BuildBranchesCard(QuestSO s)
{
var card = SkillModule.MakeCard();
card.style.flexDirection = FlexDirection.Column;
var title = new Label("完成后分支");
title.style.fontSize = 11;
title.style.opacity = 0.55f;
title.style.marginBottom = 4;
title.style.unityFontStyleAndWeight = FontStyle.Bold;
card.Add(title);
foreach (var branch in s.branches)
{
var row = new VisualElement();
row.style.flexDirection = FlexDirection.Row;
row.style.marginBottom = 2;
string condition = branch.conditionQuest != null ? branch.conditionQuest.questId : "(默认)";
string next = branch.nextQuest != null ? branch.nextQuest.name : "(无)";
SkillModule.AddChip(row, "条件", condition);
SkillModule.AddChip(row, "后续任务", next);
// 优先显示 SO 引用名称
string seqName = branch.npcDialogueSequence != null ? branch.npcDialogueSequence.name : null;
if (!string.IsNullOrEmpty(seqName))
SkillModule.AddChip(row, "对话序列", seqName);
card.Add(row);
}
return card;
}
/// <summary>
/// 构建当前任务的依赖关系可视图(折叠面板形式):
/// - 上方:前置任务链(此任务需要哪些任务先完成)
/// - 下方:后续任务链(此任务完成后可解锁哪些任务)
/// 数据来源allQuests 中所有 QuestSO 的 prerequisites.questDependencies 引用,无运行时副作用。
/// 节点可点击→选中对应资产EditorGUIUtility.PingObject
/// </summary>
private static VisualElement BuildDependencyGraph(QuestSO s)
{
var foldout = new Foldout { text = "依赖关系", value = false };
foldout.style.paddingLeft = 12;
foldout.style.paddingRight = 12;
foldout.style.marginTop = 4;
foldout.style.marginBottom = 4;
// 懒加载:展开时才扫描资产,避免初始化开销
bool built = false;
foldout.RegisterValueChangedCallback(evt =>
{
if (!evt.newValue || built) return;
built = true;
PopulateDependencyGraph(foldout.contentContainer, s);
});
return foldout;
}
private static void PopulateDependencyGraph(VisualElement container, QuestSO s)
{
// 静态 TTL 缓存5 秒内复用上次 FindAll 结果,避免每次展开 foldout 都扫描全量资产
if (s_allQuestCache == null ||
EditorApplication.timeSinceStartup - s_allQuestCacheTime > k_AllQuestCacheTtl)
{
s_allQuestCache = AssetOperations.FindAll<QuestSO>();
s_allQuestCacheTime = EditorApplication.timeSinceStartup;
}
var allQuests = s_allQuestCache;
// ── 前置任务(上游)────────────────────────────────────────────────
bool hasPrereqs = s.prerequisites.questDependencies != null && s.prerequisites.questDependencies.Length > 0;
AddDepSection(container, "▲ 前置任务(需先完成)",
hasPrereqs
? System.Array.ConvertAll(s.prerequisites.questDependencies, q => (q, "前置"))
: null,
hasPrereqs ? null : "(无前置条件,可直接接取)");
// ── 后续任务(下游):扫描 allQuests找出以 s 为前置的任务 ───────
var downstream = new List<(QuestSO q, string label)>();
foreach (var quest in allQuests)
{
if (quest == null || quest == s) continue;
if (quest.prerequisites.questDependencies == null) continue;
foreach (var pre in quest.prerequisites.questDependencies)
{
if (pre == s) { downstream.Add((quest, "解锁")); break; }
}
}
// ── 分支后续branch.nextQuest────────────────────────────────────
if (s.branches != null)
{
foreach (var branch in s.branches)
{
if (branch.nextQuest == null) continue;
string label = branch.conditionQuest != null
? $"分支(条件={branch.conditionQuest.questId}"
: "分支(默认)";
downstream.Add((branch.nextQuest, label));
}
}
AddDepSection(container, "▼ 后续任务(完成后解锁)",
downstream.Count > 0 ? downstream.ToArray() : null,
downstream.Count == 0 ? "(无后续任务)" : null);
// ── 环形依赖检测 ─────────────────────────────────────────────────
// 检查当前任务的前置链中是否存在循环引用(如 A 需要 BB 又需要 A
if (HasPrerequisiteCycle(s, s, new System.Collections.Generic.HashSet<string>(System.StringComparer.Ordinal)))
{
var cycleWarn = new UnityEngine.UIElements.Label("⚠ 检测到前置任务循环引用!此任务永远无法接取,请检查前置任务链。");
cycleWarn.style.color = new StyleColor(new UnityEngine.Color(1f, 0.4f, 0.2f));
cycleWarn.style.fontSize = 11;
cycleWarn.style.marginTop = 6;
cycleWarn.style.paddingLeft = 8;
cycleWarn.style.whiteSpace = WhiteSpace.Normal;
container.Add(cycleWarn);
}
}
/// <summary>
/// 递归检测任务是否存在循环前置依赖DFS
/// visited 存储已访问的 questIdorigin 为检测起点。
/// </summary>
private static bool HasPrerequisiteCycle(QuestSO origin, QuestSO current, System.Collections.Generic.HashSet<string> visited)
{
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; // 回到起点,发现循环
if (!visited.Add(pre.questId)) continue; // 已访问,跳过防止重复 DFS
if (HasPrerequisiteCycle(origin, pre, visited)) return true;
}
return false;
}
/// <summary>添加一个依赖关系分区(标题 + 节点列表)。</summary>
private static void AddDepSection(VisualElement container,
string sectionTitle,
(QuestSO q, string label)[] items,
string emptyText)
{
var header = new Label(sectionTitle);
header.style.fontSize = 10;
header.style.opacity = 0.55f;
header.style.marginTop = 6;
header.style.marginBottom = 3;
header.style.unityFontStyleAndWeight = FontStyle.Bold;
container.Add(header);
if (items == null || items.Length == 0)
{
var empty = new Label(emptyText ?? "(无)");
empty.style.fontSize = 11;
empty.style.opacity = 0.4f;
empty.style.paddingLeft = 10;
container.Add(empty);
return;
}
foreach (var (q, label) in items)
{
if (q == null) continue;
var row = new VisualElement();
row.style.flexDirection = FlexDirection.Row;
row.style.alignItems = Align.Center;
row.style.marginBottom = 2;
row.style.paddingLeft = 10;
// 关系标签徽章
var badge = new Label($"[{label}]");
badge.style.fontSize = 9;
badge.style.opacity = 0.6f;
badge.style.marginRight = 5;
badge.style.flexShrink = 0;
row.Add(badge);
// 任务名按钮(点击 Ping 资产)
string displayName = string.IsNullOrEmpty(q.questId) ? q.name : q.questId;
var btn = new Button(() => EditorGUIUtility.PingObject(q)) { text = displayName };
btn.style.fontSize = 11;
btn.style.flexGrow = 1;
btn.style.paddingTop = 1;
btn.style.paddingBottom = 1;
btn.style.unityTextAlign = TextAnchor.MiddleLeft;
row.Add(btn);
container.Add(row);
}
}
private VisualElement BuildActionBar(QuestSO s)
{
var bar = SkillModule.BuildStandardActionBar(
s, Folder, Prefix,
onCreated: c => _listPane.Refresh(c),
onCloned: c => _listPane.Refresh(c),
onDeleted: () => _listPane.Refresh(null),
wizardCreate: cb => AssetCreationWizard.Show<QuestSO>(
Folder, Prefix,
(q, id) =>
{
q.questId = id;
EditorUtility.SetDirty(q);
AssetDatabase.SaveAssets();
cb(q);
}));
// 任务模块额外:代码常量生成 + 批量配置验证
new Button(GenerateQuestKeys) { text = "生成常量" }.AlsoAddTo(bar);
new Button(ValidateAllQuests) { text = "批量验证" }.AlsoAddTo(bar);
// 运行时模拟按钮(仅 PlayMode 可用)
var simulateBtn = new Button(() => SimulateQuest(_selected)) { text = "▶ 模拟" };
simulateBtn.tooltip =
"PlayMode 下推进任务状态机:\n" +
" • Available → AcceptQuest接取\n" +
" • Active → 弹窗选择CompleteQuest 或 AbandonQuest\n" +
" • 其他状态 → ResetQuest重置为 Available 供重测)\n" +
"EditMode 下按钮灰显。";
simulateBtn.SetEnabled(UnityEditor.EditorApplication.isPlaying);
// 退订旧订阅,避免每次 BuildDetailPane 时重复追加 lambda 导致内存泄漏
if (_playModeHandler != null)
UnityEditor.EditorApplication.playModeStateChanged -= _playModeHandler;
_playModeHandler = s =>
{
bool playing = s == UnityEditor.PlayModeStateChange.EnteredPlayMode
|| UnityEditor.EditorApplication.isPlaying;
simulateBtn.SetEnabled(playing);
};
UnityEditor.EditorApplication.playModeStateChanged += _playModeHandler;
simulateBtn.AlsoAddTo(bar);
return bar;
}
// ── 运行时模拟 ────────────────────────────────────────────────────────
/// <summary>
/// PlayMode 下对当前选中的 QuestSO 模拟状态推进或重置:
/// - Available → AcceptQuest
/// - Active → CompleteQuest传入 null rewardTarget跳过奖励发放
/// - Completed / Failed / Unavailable → ResetQuest重置为 Available 供重测)
/// 用于策划/开发人员在不启动游戏流程的情况下快速验证任务状态机。
/// </summary>
private static void SimulateQuest(QuestSO quest)
{
if (!UnityEditor.EditorApplication.isPlaying)
{
UnityEditor.EditorUtility.DisplayDialog("模拟测试", "请先进入 PlayMode。", "确定");
return;
}
if (quest == null)
{
Debug.LogWarning("[QuestModule] 请先在左侧列表选中一个任务再点击模拟。");
return;
}
var qm = BaseGames.Core.ServiceLocator.GetOrDefault<IQuestManager>();
if (qm == null)
{
Debug.LogWarning("[QuestModule] IQuestManager 未注册到 ServiceLocator请确认 QuestManager 已在场景中。");
return;
}
var state = qm.GetState(quest.questId);
switch (state)
{
case BaseGames.Core.Events.QuestState.Available:
qm.AcceptQuest(quest.questId);
Debug.Log($"[QuestModule] 模拟接受任务:{quest.questId}");
break;
case BaseGames.Core.Events.QuestState.Active:
// Active 状态提供三个操作:完成 / 暂停 / 放弃
int choice = UnityEditor.EditorUtility.DisplayDialogComplex(
"模拟 Active 任务",
$"任务 [{quest.questId}] 当前进行中,请选择操作:",
"完成任务", // 0
"取消", // 1
"暂停任务"); // 2
if (choice == 0)
{
qm.CompleteQuest(quest.questId, null);
Debug.Log($"[QuestModule] 模拟完成任务:{quest.questId}");
}
else if (choice == 2)
{
qm.PauseQuest(quest.questId);
Debug.Log($"[QuestModule] 模拟暂停任务:{quest.questId}");
}
break;
case BaseGames.Core.Events.QuestState.Paused:
// Paused 状态:恢复 或 放弃
int pauseChoice = UnityEditor.EditorUtility.DisplayDialogComplex(
"模拟 Paused 任务",
$"任务 [{quest.questId}] 当前已暂停,请选择操作:",
"恢复任务", // 0
"取消", // 1
"放弃任务"); // 2
if (pauseChoice == 0)
{
qm.ResumeQuest(quest.questId);
Debug.Log($"[QuestModule] 模拟恢复任务:{quest.questId}");
}
else if (pauseChoice == 2)
{
// Paused 不能直接调用 AbandonQuest需先恢复
qm.ResumeQuest(quest.questId);
qm.AbandonQuest(quest.questId);
Debug.Log($"[QuestModule] 模拟放弃暂停中的任务:{quest.questId}");
}
break;
default:
// Completed / Failed / Unavailable → 通过 IQuestDebugger 重置为 Available 供重测
#if UNITY_EDITOR || DEVELOPMENT_BUILD
if (qm is IQuestDebugger debugger)
{
debugger.ResetQuest(quest.questId);
Debug.Log($"[QuestModule] 任务 '{quest.questId}' 已从 [{state}] 重置,可重新接取。");
}
else
{
Debug.LogWarning($"[QuestModule] IQuestManager 未实现 IQuestDebugger无法重置任务 '{quest.questId}'。");
}
#endif
break;
}
}
// ── 批量验证 ─────────────────────────────────────────────────────────
/// <summary>
/// 遍历所有 QuestSO执行以下检查并汇总结果
/// 1. questId 为空
/// 2. questId 重复
/// 3. objectives 为空(无目标任务)
/// 4. prerequisites.questDependencies 含空引用
/// 5. 前置任务循环依赖DFS
/// 6. canFail=true 但 failConditions 为空
/// 7. reward.affinityBonus != 0 但 giverNpc 为空(好感度会丢失)
/// 8. TriggerZone ↔ ReachAreaObjective markerTag 孤儿交叉检测
/// 9. 同任务内 objectiveId 重复(运行时 compositeKey 碰撞)
/// 10. branches[i].conditionFlagEntries 含空 flagId策划配置遗漏 flag 名)
/// 11. reward.itemIds 含空白字符串或无对应 Collectible 预制件(孤儿奖励 ID
/// 结果在可交互的 QuestValidationResultWindow 中展示,每项问题附"选中"按钮可一键定位资产。
/// </summary>
private static void ValidateAllQuests()
{
var allQuests = AssetOperations.FindAll<QuestSO>();
var issues = new List<QuestValidationResultWindow.Issue>();
int errorCount = 0, warnCount = 0;
void AddError(string msg, UnityEngine.Object asset = null)
{
issues.Add(new QuestValidationResultWindow.Issue { message = msg, isError = true, asset = asset });
errorCount++;
}
void AddWarn(string msg, UnityEngine.Object asset = null)
{
issues.Add(new QuestValidationResultWindow.Issue { message = msg, isError = false, asset = asset });
warnCount++;
}
var idMap = ValidateIds(allQuests, AddError);
ValidateStructure(allQuests, idMap, AddError, AddWarn);
ValidateTriggerZones(AddWarn);
ValidateObjectiveIds(allQuests, AddError);
ValidateBranchFlags(allQuests, AddWarn);
ValidateRewards(allQuests, AddWarn);
Debug.Log($"[QuestModule] 验证完成:{allQuests.Count} 个任务,{errorCount} 个错误,{warnCount} 个警告。");
QuestValidationResultWindow.Show(issues, errorCount, warnCount, allQuests.Count, "任务批量验证结果", "任务");
}
// 检查 1 & 2空 questId / 重复 questId返回 id→SO 映射供后续检查使用
private static Dictionary<string, QuestSO> ValidateIds(
List<QuestSO> allQuests,
System.Action<string, UnityEngine.Object> addError)
{
var idMap = new Dictionary<string, QuestSO>(StringComparer.Ordinal);
foreach (var q in allQuests)
{
if (string.IsNullOrWhiteSpace(q.questId))
{
addError($"{q.name}: questId 为空,任务无法被系统引用。", q);
continue;
}
if (idMap.TryGetValue(q.questId, out var existing))
addError($"重复 questId \"{q.questId}\"{q.name} 与 {existing.name}", q);
else
idMap[q.questId] = q;
}
return idMap;
}
// 检查 37结构完整性无目标、空引用前置、循环依赖、canFail 配置、好感度配置)
private static void ValidateStructure(
List<QuestSO> allQuests,
Dictionary<string, QuestSO> idMap,
System.Action<string, UnityEngine.Object> addError,
System.Action<string, UnityEngine.Object> addWarn)
{
foreach (var q in allQuests)
{
if (string.IsNullOrWhiteSpace(q.questId)) continue;
if (q.objectives == null || q.objectives.Length == 0)
addWarn($"{q.questId}: objectives 为空,任务无任何目标。", q);
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.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} 但 giverNpc 未配置,好感度增量将丢失。", q);
}
}
// 检查 8TriggerZone ↔ ReachAreaObjective markerTag 孤儿交叉检测
private static void ValidateTriggerZones(System.Action<string, UnityEngine.Object> addWarn)
{
var reachTagToSO = new Dictionary<string, BaseGames.Quest.ReachAreaObjective>(StringComparer.Ordinal);
foreach (var obj in AssetOperations.FindAll<BaseGames.Quest.ReachAreaObjective>())
if (!string.IsNullOrEmpty(obj.markerTag))
reachTagToSO[obj.markerTag] = obj;
var triggerTagToPrefab = new Dictionary<string, GameObject>(StringComparer.Ordinal);
foreach (var guid in AssetDatabase.FindAssets("t:Prefab"))
{
var prefabPath = AssetDatabase.GUIDToAssetPath(guid);
var prefabGo = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
if (prefabGo == null) continue;
foreach (var zone in prefabGo.GetComponentsInChildren<BaseGames.World.TriggerZone>(true))
if (!string.IsNullOrEmpty(zone.MarkerTag))
triggerTagToPrefab.TryAdd(zone.MarkerTag, prefabGo);
}
foreach (var (tag, so) in reachTagToSO)
if (!triggerTagToPrefab.ContainsKey(tag))
addWarn($"ReachAreaObjective.markerTag=\"{tag}\" 无对应 Prefab 中的 TriggerZone孤儿目标 Tag。", so);
foreach (var (tag, prefab) in triggerTagToPrefab)
if (!reachTagToSO.ContainsKey(tag))
addWarn($"TriggerZone.markerTag=\"{tag}\" 无对应 ReachAreaObjective孤儿触发器 Tag。", prefab);
}
// 检查 9同任务内 objectiveId 重复
private static void ValidateObjectiveIds(
List<QuestSO> allQuests,
System.Action<string, UnityEngine.Object> addError)
{
foreach (var q in allQuests)
{
if (q.objectives == null || q.objectives.Length == 0) continue;
var seenIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var obj in q.objectives)
{
if (obj == null || string.IsNullOrEmpty(obj.objectiveId)) continue;
if (!seenIds.Add(obj.objectiveId))
addError($"任务 '{q.questId}' 存在重复 objectiveId '{obj.objectiveId}',运行时状态将互串。", q);
}
}
}
// 检查 10branches[i].conditionFlagEntries 含空 flagId
private static void ValidateBranchFlags(
List<QuestSO> allQuests,
System.Action<string, UnityEngine.Object> addWarn)
{
foreach (var q in allQuests)
{
if (q.branches == null || q.branches.Length == 0) continue;
for (int bi = 0; bi < q.branches.Length; bi++)
{
var branch = q.branches[bi];
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);
}
}
}
// 检查 11reward.itemIds 含空白字符串或无对应 Collectible 预制件
private static void ValidateRewards(
List<QuestSO> allQuests,
System.Action<string, UnityEngine.Object> addWarn)
{
var knownIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var guid in AssetDatabase.FindAssets("t:Prefab"))
{
var prefabPath = AssetDatabase.GUIDToAssetPath(guid);
var go = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
if (go == null) continue;
var col = go.GetComponent<BaseGames.World.Collectible>();
if (col == null) continue;
var so = new UnityEditor.SerializedObject(col);
var idProp = so.FindProperty("_collectibleId") ?? so.FindProperty("collectibleId");
if (idProp != null && !string.IsNullOrEmpty(idProp.stringValue))
knownIds.Add(idProp.stringValue);
}
foreach (var q in allQuests)
{
if (q.reward == null || q.reward.itemIds == null) continue;
for (int ii = 0; ii < q.reward.itemIds.Length; ii++)
{
var itemId = q.reward.itemIds[ii];
if (string.IsNullOrWhiteSpace(itemId))
addWarn($"任务 '{q.questId}' reward.itemIds[{ii}] 为空白字符串,将被跳过。", q);
else if (knownIds.Count > 0 && !knownIds.Contains(itemId))
addWarn($"任务 '{q.questId}' reward.itemIds[{ii}]=\"{itemId}\" 在项目 Prefab 中无对应 Collectible奖励可能无效。", q);
}
}
}
private static bool HasCircularPrerequisite(QuestSO start, Dictionary<string, QuestSO> idMap,
HashSet<string> visited)
{
if (!visited.Add(start.questId)) return true;
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;
if (HasCircularPrerequisite(preQuest, idMap, visited)) return true;
}
visited.Remove(start.questId);
return false;
}
// ── QuestKeys.cs 常量生成器 ──────────────────────────────────────────
private const string GeneratedFolder = "Assets/_Game/Scripts/Generated";
private const string QuestKeysPath = GeneratedFolder + "/QuestKeys.cs";
private static void GenerateQuestKeys()
{
// 收集所有 questId
var questIds = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var q in AssetOperations.FindAll<QuestSO>())
if (!string.IsNullOrWhiteSpace(q.questId))
questIds.Add(q.questId.Trim());
// 收集所有 targetNpcId来自 TalkToNPC 目标 SO
var npcIds = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var obj in AssetOperations.FindAll<TalkToNPCObjective>())
if (!string.IsNullOrWhiteSpace(obj.targetNpcId))
npcIds.Add(obj.targetNpcId.Trim());
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated>");
sb.AppendLine("// 由 QuestModule 工具自动生成,请勿手动编辑。");
sb.AppendLine("// 重新生成DataHub → 任务 → 任意任务 → 生成常量");
sb.AppendLine("// </auto-generated>");
sb.AppendLine("namespace BaseGames.Quest");
sb.AppendLine("{");
sb.AppendLine(" /// <summary>任务 ID 常量(从 QuestSO 自动生成)。</summary>");
sb.AppendLine(" public static class QuestKeys");
sb.AppendLine(" {");
sb.AppendLine(" /// <summary>任务唯一 ID 常量。</summary>");
sb.AppendLine(" public static class Quest");
sb.AppendLine(" {");
if (questIds.Count == 0)
sb.AppendLine(" // (未发现任何 QuestSO");
foreach (var id in questIds)
sb.AppendLine($" public const string {ToIdentifier(id)} = \"{id}\";");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" /// <summary>TalkToNPC 目标中使用的 NPC ID 常量。</summary>");
sb.AppendLine(" public static class NpcId");
sb.AppendLine(" {");
if (npcIds.Count == 0)
sb.AppendLine(" // (未发现任何 TalkToNPCObjective");
foreach (var id in npcIds)
sb.AppendLine($" public const string {ToIdentifier(id)} = \"{id}\";");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine("}");
if (!Directory.Exists(GeneratedFolder))
Directory.CreateDirectory(GeneratedFolder);
File.WriteAllText(QuestKeysPath, sb.ToString(), Encoding.UTF8);
AssetDatabase.Refresh();
Debug.Log($"[QuestModule] QuestKeys.cs 已生成:{questIds.Count} 个任务 ID{npcIds.Count} 个 NPC ID。");
EditorUtility.DisplayDialog("生成成功",
$"QuestKeys.cs 已写入 {QuestKeysPath}\n任务 ID: {questIds.Count} NPC ID: {npcIds.Count}",
"确定");
}
/// <summary>将任意字符串转换为合法的 C# 标识符PascalCase。C# 保留关键字加 @ 前缀。</summary>
private static string ToIdentifier(string raw)
{
if (string.IsNullOrEmpty(raw)) return "_Empty";
var parts = raw.Split('_', '-', ' ', '.', '/');
var sb = new StringBuilder();
foreach (var part in parts)
{
if (part.Length == 0) continue;
sb.Append(char.ToUpperInvariant(part[0]));
if (part.Length > 1) sb.Append(part.Substring(1));
}
string result = sb.ToString();
if (result.Length > 0 && char.IsDigit(result[0]))
result = "_" + result;
if (string.IsNullOrEmpty(result)) return "_Empty";
// C# 保留关键字加 @ 前缀,避免生成无法编译的代码
if (s_CSharpKeywords.Contains(result))
result = "@" + result;
return result;
}
private static readonly HashSet<string> s_CSharpKeywords = new HashSet<string>(
System.StringComparer.Ordinal)
{
"abstract","as","base","bool","break","byte","case","catch","char","checked",
"class","const","continue","decimal","default","delegate","do","double","else",
"enum","event","explicit","extern","false","finally","fixed","float","for",
"foreach","goto","if","implicit","in","int","interface","internal","is","lock",
"long","namespace","new","null","object","operator","out","override","params",
"private","protected","public","readonly","ref","return","sbyte","sealed",
"short","sizeof","stackalloc","static","string","struct","switch","this",
"throw","true","try","typeof","uint","ulong","unchecked","unsafe","ushort",
"using","virtual","void","volatile","while"
};
}
}