using System; using UnityEditor; using UnityEditor.UIElements; using UnityEngine; using UnityEngine.UIElements; using BaseGames.Dialogue; using BaseGames.Quest; using BaseGames.Editor.Shared; using BaseGames.Localization; namespace BaseGames.Editor.Modules { /// /// DataHub NPC 模块 —— 管理 NpcSO 资产。 /// 统一查看、创建、重命名、删除 NPC 定义(ID、名称 Key、头像、好感度上限)。 /// public class NpcModule : IDataModule, IDataModuleOrdered { private const string Folder = "Assets/_Game/Data/NPC"; private const string Prefix = "NPC_"; public string ModuleId => "npc"; public string DisplayName => "NPC"; public string IconName => "d_GameObject Icon"; public int DisplayOrder => 90; private SoListPane _listPane; private DetailHeader _header; private NpcSO _selected; public void Initialize() { _listPane = new SoListPane( Folder, Prefix, n => n.maxAffinity > 0 ? $"亲密{n.maxAffinity}" : null); // 扩展搜索:npcId + nameKey _listPane.GetExtraSearchText = n => $"{n.npcId} {n.nameKey}"; } 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 filterAffinity = false, filterPortrait = false; void RebuildFilter() { if (!filterAffinity && !filterPortrait) { _listPane.ExtraFilter = null; return; } _listPane.ExtraFilter = n => { if (filterAffinity && n.maxAffinity <= 0) return false; if (filterPortrait && n.portrait == null) return false; return true; }; } filterRow.Add(DataHubEditorKit.MakeFilterChip("有好感度", v => { filterAffinity = v; RebuildFilter(); })); filterRow.Add(DataHubEditorKit.MakeFilterChip("有头像", v => { filterPortrait = v; RebuildFilter(); })); container.Add(_listPane); _listPane.Refresh(); } public void BuildDetailPane(VisualElement container, UnityEngine.Object selected) { _selected = selected as NpcSO; _header = new DetailHeader(); _header.SetAsset(_selected); _header.RenameRequested += OnRenameRequested; container.Add(_header); if (_selected == null) return; container.Add(BuildInfoCard(_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(NpcSO n) { var card = SkillModule.MakeCard(); SkillModule.AddChip(card, "NPC ID", string.IsNullOrEmpty(n.npcId) ? "(未设置)" : n.npcId); string nameDisplay = string.IsNullOrEmpty(n.nameKey) ? "(未设置)" : (BaseGames.Localization.LocalizationManager.GetEditorPreview(n.nameKey, LocalizationTable.Dialogue) ?? n.nameKey); SkillModule.AddChip(card, "名称", nameDisplay); if (!string.IsNullOrEmpty(n.nameKey)) SkillModule.AddChip(card, "名称 Key", n.nameKey); if (n.maxAffinity > 0) SkillModule.AddChip(card, "好感度上限", n.maxAffinity.ToString()); // 头像预览 if (n.portrait != null) { var row = new VisualElement(); row.style.flexDirection = FlexDirection.Row; row.style.alignItems = Align.Center; row.style.paddingLeft = 8; row.style.paddingTop = 4; var img = new Image { image = n.portrait.texture }; img.style.width = 40; img.style.height = 40; img.style.borderTopLeftRadius = 4; img.style.borderTopRightRadius = 4; img.style.borderBottomLeftRadius = 4; img.style.borderBottomRightRadius = 4; row.Add(img); card.Add(row); } // 关联任务反查:显示哪些任务以此 NPC 为发布者 var referencingQuests = FindQuestsReferencingNpc(n); if (referencingQuests.Count > 0) { SkillModule.AddChip(card, "关联任务", $"共 {referencingQuests.Count} 个"); var refFold = new UnityEngine.UIElements.Foldout { text = $"关联任务({referencingQuests.Count})", value = false, }; refFold.style.paddingLeft = 8; foreach (var q in referencingQuests) { var btn = new UnityEngine.UIElements.Button(() => UnityEditor.EditorGUIUtility.PingObject(q)) { text = string.IsNullOrEmpty(q.questId) ? q.name : $"{q.questId} ({q.name})" }; btn.style.unityTextAlign = UnityEngine.TextAnchor.MiddleLeft; btn.style.fontSize = 10; btn.style.marginBottom = 1; refFold.Add(btn); } card.Add(refFold); } return card; } // ── 关联任务缓存(5 秒 TTL,避免每次切换 NPC 时全量扫描资产数据库)────────────── private static System.Collections.Generic.Dictionary> s_npcQuestCache; private static double s_npcQuestCacheTime = -10.0; private static System.Collections.Generic.List FindQuestsReferencingNpc(NpcSO n) { double now = UnityEditor.EditorApplication.timeSinceStartup; if (s_npcQuestCache != null && now - s_npcQuestCacheTime < 5.0) { s_npcQuestCache.TryGetValue(n, out var cached); return cached ?? new System.Collections.Generic.List(); } // TTL 过期,重建全量缓存(单次扫描所有 QuestSO,分组存储) s_npcQuestCache = new System.Collections.Generic.Dictionary>(); var guids = UnityEditor.AssetDatabase.FindAssets("t:QuestSO"); foreach (var guid in guids) { var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid); var q = UnityEditor.AssetDatabase.LoadAssetAtPath(path); if (q == null || q.giverNpc == null) continue; if (!s_npcQuestCache.TryGetValue(q.giverNpc, out var list)) { list = new System.Collections.Generic.List(); s_npcQuestCache[q.giverNpc] = list; } list.Add(q); } s_npcQuestCacheTime = now; s_npcQuestCache.TryGetValue(n, out var result); return result ?? new System.Collections.Generic.List(); } private VisualElement BuildActionBar(NpcSO n) { var bar = SkillModule.BuildStandardActionBar( n, Folder, Prefix, onCreated: c => _listPane.Refresh(c), onCloned: c => _listPane.Refresh(c), onDeleted: () => _listPane.Refresh(null), wizardCreate: cb => AssetCreationWizard.Show( Folder, Prefix, (npc, id) => { npc.npcId = id; EditorUtility.SetDirty(npc); AssetDatabase.SaveAssets(); cb(npc); })); // 批量验证按钮 var validateBtn = new Button(ValidateAllNpcs) { text = "批量验证" }; validateBtn.style.marginLeft = 4; bar.Add(validateBtn); return bar; } // ── 批量验证 ───────────────────────────────────────────────────────── /// /// 遍历所有 NpcSO,检查: /// 1. npcId 为空 /// 2. npcId 重复(全局) /// 3. nameKey 为空(NPC 无显示名称) /// 4. maxAffinity > 0 但 portrait 为 null(好感度 UI 无头像可展示) /// 5. nameKey 在本地化表中不存在 /// 6. interactPromptKey 非空但在本地化表中不存在 /// 7. 与同 npcId 的 DialogueActorSO portrait 不一致 /// 结果在 QuestValidationResultWindow 中展示,每项问题附"选中"按钮可一键定位资产。 /// private static void ValidateAllNpcs() { var allNpcs = AssetOperations.FindAll(); var issues = new System.Collections.Generic.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 locCache = new System.Collections.Generic.Dictionary(System.StringComparer.Ordinal); string GetLoc(string key) { if (locCache.TryGetValue(key, out var v)) return v; v = BaseGames.Localization.LocalizationManager.GetEditorPreview(key, LocalizationTable.Dialogue); locCache[key] = v; return v; } // 预构建 DialogueActorSO 映射 actorId → ActorSO(用于 portrait 一致性检查) var actorMap = new System.Collections.Generic.Dictionary( System.StringComparer.Ordinal); var actorGuids = UnityEditor.AssetDatabase.FindAssets("t:DialogueActorSO"); foreach (var g in actorGuids) { var path = UnityEditor.AssetDatabase.GUIDToAssetPath(g); var actor = UnityEditor.AssetDatabase.LoadAssetAtPath(path); if (actor != null && !string.IsNullOrEmpty(actor.actorId) && !actorMap.ContainsKey(actor.actorId)) actorMap[actor.actorId] = actor; } // 1 & 2:空 npcId / 重复 npcId var idMap = new System.Collections.Generic.Dictionary(System.StringComparer.Ordinal); foreach (var n in allNpcs) { if (string.IsNullOrWhiteSpace(n.npcId)) { AddError($"{n.name}: npcId 为空,NPC 无法被系统引用。", n); continue; } if (idMap.TryGetValue(n.npcId, out var existing)) AddError($"重复 npcId \"{n.npcId}\":{n.name} 与 {existing.name}", n); else idMap[n.npcId] = n; } // 3-7:其余字段检查 foreach (var n in allNpcs) { // 3. nameKey 为空 if (string.IsNullOrEmpty(n.nameKey)) AddWarn($"{n.name}({n.npcId}): nameKey 为空,运行时显示空名称。", n); // 4. 有好感度但无头像 if (n.maxAffinity > 0 && n.portrait == null) AddWarn($"{n.name}({n.npcId}): maxAffinity={n.maxAffinity} 但 portrait 为 null,好感度进度条 UI 无头像可展示。", n); // 5. nameKey 本地化不存在 if (!string.IsNullOrEmpty(n.nameKey) && GetLoc(n.nameKey) == null) AddWarn($"{n.name}({n.npcId}): nameKey \"{n.nameKey}\" 在本地化表中不存在。", n); // 5b. nameKey 格式异常(含空格或非法字符) if (!string.IsNullOrEmpty(n.nameKey) && !System.Text.RegularExpressions.Regex.IsMatch(n.nameKey, @"^[\w\-\.]+$")) AddWarn($"{n.name}({n.npcId}): nameKey \"{n.nameKey}\" 含有空格或非法字符,建议只使用字母、数字、_、-、.。", n); // 6. interactPromptKey 本地化不存在 if (!string.IsNullOrEmpty(n.interactPromptKey) && GetLoc(n.interactPromptKey) == null) AddWarn($"{n.name}({n.npcId}): interactPromptKey \"{n.interactPromptKey}\" 在本地化表中不存在,运行时交互提示显示 Key 原文。", n); // 7. portrait 与同 npcId 的 DialogueActorSO 不一致 if (!string.IsNullOrEmpty(n.npcId) && actorMap.TryGetValue(n.npcId, out var actor)) { if (actor.portrait != n.portrait) AddWarn($"{n.name}({n.npcId}): portrait 与 DialogueActorSO \"{actor.name}\" 的 portrait 不一致," + "对话框中显示的头像与 NPC 信息面板头像可能不同。", n); } } UnityEngine.Debug.Log($"[NpcModule] 验证完成:{allNpcs.Count} 个 NPC,{errorCount} 个错误,{warnCount} 个警告。"); QuestValidationResultWindow.Show(issues, errorCount, warnCount, allNpcs.Count, "NPC 批量验证结果", "NPC"); } } }