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 { /// /// DataHub 任务模块 —— 管理 QuestSO 资产。 /// 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 _listPane; private DetailHeader _header; private QuestSO _selected; // playModeStateChanged 订阅的字段引用,便于在重建 ActionBar 时退订旧订阅,避免内存泄漏 private System.Action _playModeHandler; public void Initialize() { _listPane = new SoListPane( Folder, Prefix, s => { bool hasPre = s.prerequisiteQuests != null && s.prerequisiteQuests.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 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.prerequisiteQuests == null || q.prerequisiteQuests.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 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(_ => 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:优先显示 giverNpc.npcId,回退旧 giverNpcId string giverId = s.GiverNpcId; if (!string.IsNullOrEmpty(giverId)) SkillModule.AddChip(card, "发布 NPC", giverId); if (s.prerequisiteQuests != null && s.prerequisiteQuests.Length > 0) { // 显示每个前置任务的 questId,方便策划一眼看清依赖链 var preIds = new System.Text.StringBuilder(); foreach (var pre in s.prerequisiteQuests) { 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 引用;回退到旧字段(Obsolete) string seqName = branch.npcDialogueSequence != null ? branch.npcDialogueSequence.name #pragma warning disable CS0618 : branch.npcDialogueKey; #pragma warning restore CS0618 if (!string.IsNullOrEmpty(seqName)) SkillModule.AddChip(row, "对话序列", seqName); card.Add(row); } return card; } /// /// 构建当前任务的依赖关系可视图(折叠面板形式): /// - 上方:前置任务链(此任务需要哪些任务先完成) /// - 下方:后续任务链(此任务完成后可解锁哪些任务) /// 数据来源:allQuests 中所有 QuestSO 的 prerequisiteQuests 引用,无运行时副作用。 /// 节点可点击→选中对应资产(EditorGUIUtility.PingObject)。 /// 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) { var allQuests = AssetOperations.FindAll(); // ── 前置任务(上游)──────────────────────────────────────────────── bool hasPrereqs = s.prerequisiteQuests != null && s.prerequisiteQuests.Length > 0; AddDepSection(container, "▲ 前置任务(需先完成)", hasPrereqs ? System.Array.ConvertAll(s.prerequisiteQuests, 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.prerequisiteQuests == null) continue; foreach (var pre in quest.prerequisiteQuests) { 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 需要 B,B 又需要 A) if (HasPrerequisiteCycle(s, s, new System.Collections.Generic.HashSet(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); } } /// /// 递归检测任务是否存在循环前置依赖(DFS)。 /// visited 存储已访问的 questId,origin 为检测起点。 /// private static bool HasPrerequisiteCycle(QuestSO origin, QuestSO current, System.Collections.Generic.HashSet visited) { if (current?.prerequisiteQuests == null) return false; foreach (var pre in current.prerequisiteQuests) { 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; } /// 添加一个依赖关系分区(标题 + 节点列表)。 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( 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; } // ── 运行时模拟 ──────────────────────────────────────────────────────── /// /// PlayMode 下对当前选中的 QuestSO 模拟状态推进或重置: /// - Available → AcceptQuest /// - Active → CompleteQuest(传入 null rewardTarget,跳过奖励发放) /// - Completed / Failed / Unavailable → ResetQuest(重置为 Available 供重测) /// 用于策划/开发人员在不启动游戏流程的情况下快速验证任务状态机。 /// 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(); 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; } } // ── 批量验证 ───────────────────────────────────────────────────────── /// /// 遍历所有 QuestSO,执行以下检查并汇总结果: /// 1. questId 为空 /// 2. questId 重复 /// 3. objectives 为空(无目标任务) /// 4. prerequisiteQuests 含空引用 /// 5. 前置任务循环依赖(DFS) /// 6. canFail=true 但 failCondition 为空 /// 7. reward.affinityBonus != 0 但 giverNpcId 为空(好感度会丢失) /// 8. TriggerZone ↔ ReachAreaObjective markerTag 孤儿交叉检测 /// 9. 同任务内 objectiveId 重复(运行时 compositeKey 碰撞) /// 10. branches[i].conditionFlags 含空白字符串(策划配置遗漏 flag 名) /// 11. reward.itemIds 含空白字符串或无对应 Collectible 预制件(孤儿奖励 ID) /// 结果在可交互的 QuestValidationResultWindow 中展示,每项问题附"选中"按钮可一键定位资产。 /// private static void ValidateAllQuests() { var allQuests = AssetOperations.FindAll(); var issues = new List(); 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 ValidateIds( List allQuests, System.Action addError) { var idMap = new Dictionary(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; } // 检查 3–7:结构完整性(无目标、空引用前置、循环依赖、canFail 配置、好感度配置) private static void ValidateStructure( List allQuests, Dictionary idMap, System.Action addError, System.Action 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.prerequisiteQuests != null) foreach (var pre in q.prerequisiteQuests) if (pre == null) { addWarn($"{q.questId}: prerequisiteQuests 含空引用,请清理 Inspector 中的空槽。", q); break; } if (HasCircularPrerequisite(q, idMap, new HashSet(StringComparer.Ordinal))) addError($"{q.questId}: 前置任务链存在循环依赖,将导致任务永远无法变为 Available!", q); if (q.canFail && q.failCondition == null) addWarn($"{q.questId}: canFail=true 但 failCondition 为空,失败条件永不触发。", q); if (q.reward != null && q.reward.affinityBonus != 0 && string.IsNullOrEmpty(q.GiverNpcId)) addWarn($"{q.questId}: reward.affinityBonus={q.reward.affinityBonus} 但 GiverNpcId 为空,好感度增量将丢失。", q); } } // 检查 8:TriggerZone ↔ ReachAreaObjective markerTag 孤儿交叉检测 private static void ValidateTriggerZones(System.Action addWarn) { var reachTagToSO = new Dictionary(StringComparer.Ordinal); foreach (var obj in AssetOperations.FindAll()) if (!string.IsNullOrEmpty(obj.markerTag)) reachTagToSO[obj.markerTag] = obj; var triggerTagToPrefab = new Dictionary(StringComparer.Ordinal); foreach (var guid in AssetDatabase.FindAssets("t:Prefab")) { var prefabPath = AssetDatabase.GUIDToAssetPath(guid); var prefabGo = AssetDatabase.LoadAssetAtPath(prefabPath); if (prefabGo == null) continue; foreach (var zone in prefabGo.GetComponentsInChildren(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 allQuests, System.Action addError) { foreach (var q in allQuests) { if (q.objectives == null || q.objectives.Length == 0) continue; var seenIds = new HashSet(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); } } } // 检查 10:branches[i].conditionFlags 含空白字符串 private static void ValidateBranchFlags( List allQuests, System.Action 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.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); } } } // 检查 11:reward.itemIds 含空白字符串或无对应 Collectible 预制件 private static void ValidateRewards( List allQuests, System.Action addWarn) { var knownIds = new HashSet(StringComparer.Ordinal); foreach (var guid in AssetDatabase.FindAssets("t:Prefab")) { var prefabPath = AssetDatabase.GUIDToAssetPath(guid); var go = AssetDatabase.LoadAssetAtPath(prefabPath); if (go == null) continue; var col = go.GetComponent(); 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 idMap, HashSet visited) { if (!visited.Add(start.questId)) return true; if (start.prerequisiteQuests == null) return false; foreach (var pre in start.prerequisiteQuests) { 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(StringComparer.OrdinalIgnoreCase); foreach (var q in AssetOperations.FindAll()) if (!string.IsNullOrWhiteSpace(q.questId)) questIds.Add(q.questId.Trim()); // 收集所有 targetNpcId(来自 TalkToNPC 目标 SO) var npcIds = new SortedSet(StringComparer.OrdinalIgnoreCase); foreach (var obj in AssetOperations.FindAll()) if (!string.IsNullOrWhiteSpace(obj.targetNpcId)) npcIds.Add(obj.targetNpcId.Trim()); var sb = new StringBuilder(); sb.AppendLine("// "); sb.AppendLine("// 由 QuestModule 工具自动生成,请勿手动编辑。"); sb.AppendLine("// 重新生成:DataHub → 任务 → 任意任务 → 生成常量"); sb.AppendLine("// "); sb.AppendLine("namespace BaseGames.Quest"); sb.AppendLine("{"); sb.AppendLine(" /// 任务 ID 常量(从 QuestSO 自动生成)。"); sb.AppendLine(" public static class QuestKeys"); sb.AppendLine(" {"); sb.AppendLine(" /// 任务唯一 ID 常量。"); 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(" /// TalkToNPC 目标中使用的 NPC ID 常量。"); 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}", "确定"); } /// 将任意字符串转换为合法的 C# 标识符(PascalCase)。C# 保留关键字加 @ 前缀。 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 s_CSharpKeywords = new HashSet( 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" }; } }