- QuestSO: Add ValidateBranchCycles() DFS detection for branches[].nextQuest loop - QuestSO: Mark three legacy prerequisite fields with v2.0 removal warning in Tooltip - IQuestManager: Add QuestLockReason enum + QuestLockInfo struct (strongly-typed lock info) - IQuestManager: Add GetQuestLockInfo() method to interface; GetQuestLockReason() now delegates to it - IQuestEventSource: Add OnQuestStateChanged(questId, oldState, newState) unified event - QuestManager: Implement GetQuestLockInfo(); fire OnQuestStateChanged on all state transitions - DialogueManager: Add one-frame yield in HandleChoices before ShowChoices (skip-debounce fix) - DialogueManager: Increment _playbackId in ForceEnd() to invalidate residual choice callbacks - DialogueSequenceSO: Add UNITY_EDITOR debug log in TryGetActiveVariant on variant match - WorldStateRegistry: Add OnBatchStateChanged event + BatchMark() batch-write API - DialogueModule: List badge shows warning indicator for unconditional-shadowing variants - DialogueModule: BuildVariantsCard shows logic mode (AND/OR) alongside flag conditions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
336 lines
15 KiB
C#
336 lines
15 KiB
C#
using System;
|
||
using UnityEditor;
|
||
using UnityEditor.UIElements;
|
||
using UnityEngine;
|
||
using UnityEngine.UIElements;
|
||
using BaseGames.Dialogue;
|
||
using BaseGames.Quest;
|
||
using BaseGames.Editor.Shared;
|
||
|
||
namespace BaseGames.Editor.Modules
|
||
{
|
||
/// <summary>
|
||
/// DataHub NPC 模块 —— 管理 NpcSO 资产。
|
||
/// 统一查看、创建、重命名、删除 NPC 定义(ID、名称 Key、头像、好感度上限)。
|
||
/// </summary>
|
||
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<NpcSO> _listPane;
|
||
private DetailHeader _header;
|
||
private NpcSO _selected;
|
||
|
||
public void Initialize()
|
||
{
|
||
_listPane = new SoListPane<NpcSO>(
|
||
Folder, Prefix,
|
||
n => n.maxAffinity > 0 ? $"亲密{n.maxAffinity}" : null);
|
||
// 扩展搜索:npcId + nameKey
|
||
_listPane.GetExtraSearchText = n => $"{n.npcId} {n.nameKey}";
|
||
}
|
||
|
||
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 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(QuestModule.MakeFilterChip("有好感度", v => { filterAffinity = v; RebuildFilter(); }));
|
||
filterRow.Add(QuestModule.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, "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<NpcSO, System.Collections.Generic.List<QuestSO>>
|
||
s_npcQuestCache;
|
||
private static double s_npcQuestCacheTime = -10.0;
|
||
|
||
private static System.Collections.Generic.List<QuestSO> 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<QuestSO>();
|
||
}
|
||
|
||
// TTL 过期,重建全量缓存(单次扫描所有 QuestSO,分组存储)
|
||
s_npcQuestCache = new System.Collections.Generic.Dictionary<NpcSO, System.Collections.Generic.List<QuestSO>>();
|
||
var guids = UnityEditor.AssetDatabase.FindAssets("t:QuestSO");
|
||
foreach (var guid in guids)
|
||
{
|
||
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid);
|
||
var q = UnityEditor.AssetDatabase.LoadAssetAtPath<QuestSO>(path);
|
||
if (q == null || q.giverNpc == null) continue;
|
||
if (!s_npcQuestCache.TryGetValue(q.giverNpc, out var list))
|
||
{
|
||
list = new System.Collections.Generic.List<QuestSO>();
|
||
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<QuestSO>();
|
||
}
|
||
|
||
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<NpcSO>(
|
||
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;
|
||
}
|
||
|
||
// ── 批量验证 ─────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 遍历所有 NpcSO,检查:
|
||
/// 1. npcId 为空
|
||
/// 2. npcId 重复(全局)
|
||
/// 3. nameKey 为空(NPC 无显示名称)
|
||
/// 4. maxAffinity > 0 但 portrait 为 null(好感度 UI 无头像可展示)
|
||
/// 5. nameKey 在本地化表中不存在
|
||
/// 6. interactPromptKey 非空但在本地化表中不存在
|
||
/// 7. 与同 npcId 的 DialogueActorSO portrait 不一致
|
||
/// 结果在 QuestValidationResultWindow 中展示,每项问题附"选中"按钮可一键定位资产。
|
||
/// </summary>
|
||
private static void ValidateAllNpcs()
|
||
{
|
||
var allNpcs = AssetOperations.FindAll<NpcSO>();
|
||
var issues = new System.Collections.Generic.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 locCache = new System.Collections.Generic.Dictionary<string, string>(System.StringComparer.Ordinal);
|
||
string GetLoc(string key)
|
||
{
|
||
if (locCache.TryGetValue(key, out var v)) return v;
|
||
v = BaseGames.Localization.LocalizationManager.GetEditorPreview(key, "Dialogue");
|
||
locCache[key] = v;
|
||
return v;
|
||
}
|
||
|
||
// 预构建 DialogueActorSO 映射 actorId → ActorSO(用于 portrait 一致性检查)
|
||
var actorMap = new System.Collections.Generic.Dictionary<string, BaseGames.Dialogue.DialogueActorSO>(
|
||
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<BaseGames.Dialogue.DialogueActorSO>(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<string, NpcSO>(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");
|
||
}
|
||
}
|
||
}
|