Files
zeling_v2/Assets/_Game/Scripts/Editor/Modules/NpcModule.cs
Joywayer 6eaa83dc71 feat: Round 48 narrative systems improvements
- 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>
2026-05-25 00:05:15 +08:00

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