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");
}
}
}