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>
This commit is contained in:
@@ -11,7 +11,7 @@ namespace BaseGames.Editor.Modules
|
||||
/// DataHub 对话角色模块 —— 管理 DialogueActorSO 资产。
|
||||
/// 统一查看、创建、重命名、删除 NPC/玩家角色定义(头像、名称 Key、强调色)。
|
||||
/// </summary>
|
||||
public class ActorModule : IDataModule
|
||||
public class ActorModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string Folder = "Assets/_Game/Data/Dialogue/Actors";
|
||||
private const string Prefix = "Actor_";
|
||||
@@ -19,6 +19,7 @@ namespace BaseGames.Editor.Modules
|
||||
public string ModuleId => "actor";
|
||||
public string DisplayName => "角色";
|
||||
public string IconName => "d_Prefab Icon";
|
||||
public int DisplayOrder => 80;
|
||||
|
||||
private SoListPane<DialogueActorSO> _listPane;
|
||||
private DetailHeader _header;
|
||||
@@ -76,7 +77,15 @@ namespace BaseGames.Editor.Modules
|
||||
var card = SkillModule.MakeCard();
|
||||
|
||||
SkillModule.AddChip(card, "ID", string.IsNullOrEmpty(a.actorId) ? "(未设置)" : a.actorId);
|
||||
SkillModule.AddChip(card, "名称 Key", string.IsNullOrEmpty(a.nameKey) ? "(未设置)" : a.nameKey);
|
||||
|
||||
// 名称:优先显示本地化实际文本,回退到 key 本身
|
||||
string nameDisplay = string.IsNullOrEmpty(a.nameKey)
|
||||
? "(未设置)"
|
||||
: (BaseGames.Localization.LocalizationManager.GetEditorPreview(a.nameKey, "Dialogue") ?? a.nameKey);
|
||||
SkillModule.AddChip(card, "名称", nameDisplay);
|
||||
if (!string.IsNullOrEmpty(a.nameKey))
|
||||
SkillModule.AddChip(card, "名称 Key", a.nameKey);
|
||||
|
||||
if (a.isPlayer)
|
||||
SkillModule.AddChip(card, "类型", "玩家");
|
||||
|
||||
|
||||
11
Assets/_Game/Scripts/Editor/Modules/ActorModule.cs.meta
Normal file
11
Assets/_Game/Scripts/Editor/Modules/ActorModule.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1432dc664312c954e9b4adb0cbb6f25e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -10,7 +10,7 @@ namespace BaseGames.Editor.Modules
|
||||
/// <summary>
|
||||
/// DataHub Boss技能模块 —— Tab 切换管理 BossSkillSO 和 SkillSequenceSO。
|
||||
/// </summary>
|
||||
public class BossSkillModule : IDataModule
|
||||
public class BossSkillModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string SkillFolder = "Assets/_Game/Data/Boss/Skills";
|
||||
private const string SeqFolder = "Assets/_Game/Data/Boss/Sequences";
|
||||
@@ -18,6 +18,7 @@ namespace BaseGames.Editor.Modules
|
||||
public string ModuleId => "boss";
|
||||
public string DisplayName => "Boss技能";
|
||||
public string IconName => null;
|
||||
public int DisplayOrder => 50;
|
||||
|
||||
private int _activeTab = 0;
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace BaseGames.Editor.Modules
|
||||
/// <summary>
|
||||
/// DataHub 护符模块 —— Tab 切换管理 CharmCatalogSO(目录)和 CharmSO(护符)资产。
|
||||
/// </summary>
|
||||
public class CharmModule : IDataModule
|
||||
public class CharmModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string CharmFolder = "Assets/_Game/Data/Progression/Charms";
|
||||
private const string CatalogPrefix = "CHM_Catalog";
|
||||
@@ -19,6 +19,7 @@ namespace BaseGames.Editor.Modules
|
||||
public string ModuleId => "charm";
|
||||
public string DisplayName => "护符";
|
||||
public string IconName => null;
|
||||
public int DisplayOrder => 60;
|
||||
|
||||
private int _activeTab = 0; // 0 = 目录, 1 = 护符
|
||||
|
||||
|
||||
@@ -4,13 +4,15 @@ using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using BaseGames.Dialogue;
|
||||
using BaseGames.Editor.Dialogue;
|
||||
using BaseGames.Editor.Shared;
|
||||
|
||||
namespace BaseGames.Editor.Modules
|
||||
{
|
||||
/// <summary>
|
||||
/// DataHub 对话序列模块 —— 管理 DialogueSequenceSO 资产。
|
||||
/// </summary>
|
||||
public class DialogueModule : IDataModule
|
||||
public class DialogueModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string Folder = "Assets/_Game/Data/Dialogue";
|
||||
private const string Prefix = "DLG_";
|
||||
@@ -18,6 +20,7 @@ namespace BaseGames.Editor.Modules
|
||||
public string ModuleId => "dialogue";
|
||||
public string DisplayName => "对话";
|
||||
public string IconName => "d_UnityEditor.ConsoleWindow";
|
||||
public int DisplayOrder => 100;
|
||||
|
||||
private SoListPane<DialogueSequenceSO> _listPane;
|
||||
private DetailHeader _header;
|
||||
@@ -29,9 +32,20 @@ namespace BaseGames.Editor.Modules
|
||||
Folder, Prefix,
|
||||
s =>
|
||||
{
|
||||
int v = s.variants != null ? s.variants.Length : 0;
|
||||
return v > 0 ? $"{v}变体" : null;
|
||||
if (s.variants == null || s.variants.Length == 0) return null;
|
||||
int v = s.variants.Length;
|
||||
// 检测无条件变体遮蔽(非末尾的无条件变体会让后续变体永不命中)
|
||||
bool hasShadow = false;
|
||||
for (int i = 0; i < s.variants.Length - 1; i++)
|
||||
{
|
||||
var vv = s.variants[i];
|
||||
if (vv.sequence != null && (vv.requiredFlags == null || vv.requiredFlags.Length == 0))
|
||||
{ hasShadow = true; break; }
|
||||
}
|
||||
return hasShadow ? $"{v}变体 ⚠" : $"{v}变体";
|
||||
});
|
||||
// 扩展搜索:sequenceId
|
||||
_listPane.GetExtraSearchText = d => d.sequenceId;
|
||||
}
|
||||
|
||||
public void BuildListPane(VisualElement container, Action<UnityEngine.Object> onSelected)
|
||||
@@ -41,6 +55,52 @@ namespace BaseGames.Editor.Modules
|
||||
_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 filterVariants = false, filterBranches = false, filterNoVoice = false;
|
||||
|
||||
void RebuildFilter()
|
||||
{
|
||||
if (!filterVariants && !filterBranches && !filterNoVoice)
|
||||
{
|
||||
_listPane.ExtraFilter = null;
|
||||
return;
|
||||
}
|
||||
_listPane.ExtraFilter = s =>
|
||||
{
|
||||
if (filterVariants && (s.variants == null || s.variants.Length == 0)) return false;
|
||||
if (filterBranches)
|
||||
{
|
||||
bool hasBranch = false;
|
||||
if (s.lines != null)
|
||||
foreach (var l in s.lines)
|
||||
if (l.choices != null && l.choices.Length > 0) { hasBranch = true; break; }
|
||||
if (!hasBranch) return false;
|
||||
}
|
||||
if (filterNoVoice)
|
||||
{
|
||||
bool hasVoice = false;
|
||||
if (s.lines != null)
|
||||
foreach (var l in s.lines)
|
||||
if (l.voiceClip != null) { hasVoice = true; break; }
|
||||
if (hasVoice) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
filterRow.Add(QuestModule.MakeFilterChip("有变体", v => { filterVariants = v; RebuildFilter(); }));
|
||||
filterRow.Add(QuestModule.MakeFilterChip("有分支", v => { filterBranches = v; RebuildFilter(); }));
|
||||
filterRow.Add(QuestModule.MakeFilterChip("无语音", v => { filterNoVoice = v; RebuildFilter(); }));
|
||||
|
||||
container.Add(_listPane);
|
||||
_listPane.Refresh();
|
||||
}
|
||||
@@ -90,8 +150,11 @@ namespace BaseGames.Editor.Modules
|
||||
return card;
|
||||
}
|
||||
|
||||
private static VisualElement BuildLinesPreview(DialogueSequenceSO s)
|
||||
private VisualElement BuildLinesPreview(DialogueSequenceSO s)
|
||||
{
|
||||
var so = new SerializedObject(s);
|
||||
var linesProp = so.FindProperty("lines");
|
||||
|
||||
var section = new VisualElement();
|
||||
section.style.paddingLeft = 12;
|
||||
section.style.paddingRight = 12;
|
||||
@@ -114,14 +177,14 @@ namespace BaseGames.Editor.Modules
|
||||
return section;
|
||||
}
|
||||
|
||||
int preview = Mathf.Min(5, s.lines.Length);
|
||||
for (int i = 0; i < preview; i++)
|
||||
int previewCount = Mathf.Min(5, s.lines.Length);
|
||||
for (int i = 0; i < previewCount; i++)
|
||||
{
|
||||
var line = s.lines[i];
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.alignItems = Align.Center;
|
||||
row.style.marginBottom = 3;
|
||||
row.style.marginBottom = 2;
|
||||
|
||||
// 头像图标(actor 优先,回退到直接字段)
|
||||
var portrait = line.ResolvedPortrait;
|
||||
@@ -152,36 +215,126 @@ namespace BaseGames.Editor.Modules
|
||||
}
|
||||
}
|
||||
|
||||
// 说话人(actor 优先,回退到直接字段)
|
||||
// 说话人(actor 优先,回退到直接字段;尝试解析本地化实际文本)
|
||||
string speakerKey = line.ResolvedNameKey;
|
||||
if (!string.IsNullOrEmpty(speakerKey))
|
||||
{
|
||||
var spk = new Label(speakerKey + ":");
|
||||
var speakerResolved = BaseGames.Localization.LocalizationManager.GetEditorPreview(speakerKey, "Dialogue");
|
||||
bool speakerMissing = speakerResolved == null;
|
||||
string speakerText = speakerMissing ? speakerKey : speakerResolved;
|
||||
var spk = new Label(speakerText + ":");
|
||||
spk.style.fontSize = 11;
|
||||
spk.style.opacity = 0.55f;
|
||||
spk.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
spk.style.marginRight = 4;
|
||||
spk.style.flexShrink = 0;
|
||||
var accent = line.ResolvedAccentColor;
|
||||
if (speakerMissing)
|
||||
{
|
||||
// 说话人 Key 缺少本地化 → 橙色警告
|
||||
spk.style.color = new StyleColor(new Color(1f, 0.6f, 0.1f));
|
||||
spk.style.opacity = 1.0f;
|
||||
}
|
||||
else if (accent != Color.white)
|
||||
{
|
||||
spk.style.color = new StyleColor(accent);
|
||||
spk.style.opacity = 1.0f;
|
||||
}
|
||||
else
|
||||
{
|
||||
spk.style.opacity = 0.55f;
|
||||
}
|
||||
row.Add(spk);
|
||||
}
|
||||
|
||||
// 文本 key(尝试显示本地化实际内容,回退到 key 本身)
|
||||
string rawText = string.IsNullOrEmpty(line.textKey) ? "(空)" : line.textKey;
|
||||
string preview = string.IsNullOrEmpty(line.textKey)
|
||||
? "(空)"
|
||||
: (BaseGames.Localization.LocalizationManager.GetEditorPreview(line.textKey, "Dialogue") ?? rawText);
|
||||
if (preview.Length > 48) preview = preview[..48] + "…";
|
||||
var lbl = new Label(preview);
|
||||
// 文本(本地化预览;Key 有值但无本地化内容时橙色 ⚠ 警告)
|
||||
string textPreview;
|
||||
bool textL10nMissing = false;
|
||||
if (string.IsNullOrEmpty(line.textKey))
|
||||
{
|
||||
textPreview = "(空)";
|
||||
}
|
||||
else
|
||||
{
|
||||
var resolved = BaseGames.Localization.LocalizationManager.GetEditorPreview(line.textKey, "Dialogue");
|
||||
if (resolved != null)
|
||||
{
|
||||
textPreview = resolved;
|
||||
}
|
||||
else
|
||||
{
|
||||
textPreview = line.textKey + " ⚠";
|
||||
textL10nMissing = true;
|
||||
}
|
||||
}
|
||||
if (textPreview.Length > 48) textPreview = textPreview[..48] + "…";
|
||||
var lbl = new Label(textPreview);
|
||||
lbl.style.fontSize = 11;
|
||||
lbl.style.overflow = Overflow.Hidden;
|
||||
lbl.style.flexGrow = 1;
|
||||
if (textL10nMissing)
|
||||
lbl.style.color = new StyleColor(new Color(1f, 0.6f, 0.1f));
|
||||
row.Add(lbl);
|
||||
|
||||
// 选项分支徽章(有 choices 时显示"→N选")
|
||||
if (line.choices != null && line.choices.Length > 0)
|
||||
{
|
||||
var choiceBadge = new Label($"→{line.choices.Length}选");
|
||||
choiceBadge.style.fontSize = 9;
|
||||
choiceBadge.style.color = new StyleColor(new Color(0.3f, 0.8f, 1f));
|
||||
choiceBadge.style.marginLeft = 4;
|
||||
choiceBadge.style.flexShrink = 0;
|
||||
row.Add(choiceBadge);
|
||||
}
|
||||
|
||||
// ▾ 内联编辑按钮
|
||||
var editBtn = new Button { text = "▾" };
|
||||
editBtn.style.fontSize = 9;
|
||||
editBtn.style.marginLeft = 4;
|
||||
editBtn.style.paddingLeft = 3;
|
||||
editBtn.style.paddingRight = 3;
|
||||
editBtn.style.height = 16;
|
||||
row.Add(editBtn);
|
||||
|
||||
// 内联编辑区域(默认隐藏,点击 ▾ 展开)
|
||||
int capturedIdx = i;
|
||||
var editRow = new VisualElement();
|
||||
editRow.style.display = DisplayStyle.None;
|
||||
editRow.style.paddingLeft = 26;
|
||||
editRow.style.paddingRight = 8;
|
||||
editRow.style.marginBottom = 4;
|
||||
|
||||
if (linesProp != null)
|
||||
{
|
||||
var lineProp = linesProp.GetArrayElementAtIndex(capturedIdx);
|
||||
var textKeyProp = lineProp?.FindPropertyRelative("textKey");
|
||||
if (textKeyProp != null)
|
||||
{
|
||||
var tf = new TextField("文本 Key") { value = textKeyProp.stringValue };
|
||||
tf.style.fontSize = 10;
|
||||
tf.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
so.Update();
|
||||
textKeyProp.stringValue = evt.newValue;
|
||||
so.ApplyModifiedProperties();
|
||||
});
|
||||
editRow.Add(tf);
|
||||
}
|
||||
}
|
||||
|
||||
editBtn.clicked += () =>
|
||||
{
|
||||
bool open = editRow.style.display == DisplayStyle.None;
|
||||
editRow.style.display = open ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
editBtn.text = open ? "▴" : "▾";
|
||||
};
|
||||
|
||||
section.Add(row);
|
||||
section.Add(editRow);
|
||||
}
|
||||
|
||||
if (s.lines.Length > preview)
|
||||
if (s.lines.Length > previewCount)
|
||||
{
|
||||
var more = new Label($"… 还有 {s.lines.Length - preview} 行");
|
||||
var more = new Label($"… 还有 {s.lines.Length - previewCount} 行");
|
||||
more.style.opacity = 0.4f;
|
||||
more.style.fontSize = 10;
|
||||
section.Add(more);
|
||||
@@ -202,29 +355,207 @@ namespace BaseGames.Editor.Modules
|
||||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
card.Add(title);
|
||||
|
||||
foreach (var v in s.variants)
|
||||
for (int i = 0; i < s.variants.Length; i++)
|
||||
{
|
||||
var v = s.variants[i];
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.marginBottom = 2;
|
||||
|
||||
string flags = v.requiredFlags != null && v.requiredFlags.Length > 0
|
||||
? string.Join(", ", v.requiredFlags)
|
||||
: "(无条件)";
|
||||
SkillModule.AddChip(row, "条件", flags);
|
||||
SkillModule.AddChip(row, "替换序列", v.sequence != null ? v.sequence.name : "(未设置)");
|
||||
bool isUnconditional = v.requiredFlags == null || v.requiredFlags.Length == 0;
|
||||
bool isShadowing = isUnconditional && v.sequence != null && i < s.variants.Length - 1;
|
||||
|
||||
// 条件徽章:显示逻辑模式(And/Or)和标志列表
|
||||
string conditionText;
|
||||
if (isUnconditional)
|
||||
{
|
||||
conditionText = isShadowing ? "⚠ (无条件)" : "(无条件)";
|
||||
}
|
||||
else
|
||||
{
|
||||
string logicPrefix = v.requiredFlags.Length > 1
|
||||
? $"[{(v.logic == BaseGames.Core.WorldStateFlagLogic.Or ? "OR" : "AND")}] "
|
||||
: "";
|
||||
conditionText = logicPrefix + string.Join(", ", v.requiredFlags);
|
||||
}
|
||||
|
||||
SkillModule.AddChip(row, "条件", conditionText);
|
||||
|
||||
// 替换序列徽章
|
||||
string seqName = v.sequence != null ? v.sequence.name : "(未设置)";
|
||||
int seqLines = v.sequence != null && v.sequence.lines != null ? v.sequence.lines.Length : 0;
|
||||
string seqLabel = v.sequence != null ? $"{seqName}({seqLines}行)" : seqName;
|
||||
SkillModule.AddChip(row, "替换序列", seqLabel);
|
||||
|
||||
card.Add(row);
|
||||
|
||||
// 遮蔽警告行
|
||||
if (isShadowing)
|
||||
{
|
||||
int remaining = s.variants.Length - 1 - i;
|
||||
var warn = new Label($" ↑ 此变体无条件,其后 {remaining} 个变体永不生效。请移至末尾或添加条件。");
|
||||
warn.style.fontSize = 9;
|
||||
warn.style.color = new StyleColor(new Color(1f, 0.6f, 0.1f));
|
||||
warn.style.marginBottom = 2;
|
||||
card.Add(warn);
|
||||
}
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
private VisualElement BuildActionBar(DialogueSequenceSO s)
|
||||
{
|
||||
return SkillModule.BuildStandardActionBar(
|
||||
var bar = SkillModule.BuildStandardActionBar(
|
||||
s, Folder, Prefix,
|
||||
onCreated: c => _listPane.Refresh(c),
|
||||
onCloned: c => _listPane.Refresh(c),
|
||||
onDeleted: () => _listPane.Refresh(null));
|
||||
onDeleted: () => _listPane.Refresh(null),
|
||||
wizardCreate: cb => AssetCreationWizard.Show<DialogueSequenceSO>(
|
||||
Folder, Prefix,
|
||||
(d, id) =>
|
||||
{
|
||||
d.sequenceId = id;
|
||||
EditorUtility.SetDirty(d);
|
||||
AssetDatabase.SaveAssets();
|
||||
cb(d);
|
||||
}));
|
||||
|
||||
new Button(ValidateAllSequences) { text = "批量验证" }.AlsoAddTo(bar);
|
||||
|
||||
if (s.variants != null && s.variants.Length > 0)
|
||||
{
|
||||
var capturedS = s;
|
||||
new Button(() => DialogueVariantPreviewWindow.OpenWith(capturedS))
|
||||
{ text = "预览变体" }.AlsoAddTo(bar);
|
||||
}
|
||||
|
||||
return bar;
|
||||
}
|
||||
|
||||
// ── 批量验证 ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 遍历所有 DialogueSequenceSO,检查:
|
||||
/// 1. sequenceId 为空
|
||||
/// 2. sequenceId 重复
|
||||
/// 3. 每行 textKey 是否在本地化表中存在
|
||||
/// 4. 每行 speakerNameKey(无 actor 时)是否在本地化表中存在
|
||||
/// 5. 每个选项 textKey 是否在本地化表中存在
|
||||
/// 结果显示在 QuestValidationResultWindow 中,每项问题附"选中"按钮可一键定位资产。
|
||||
/// </summary>
|
||||
private static void ValidateAllSequences()
|
||||
{
|
||||
var allSeqs = AssetOperations.FindAll<DialogueSequenceSO>();
|
||||
var issues = new System.Collections.Generic.List<QuestValidationResultWindow.Issue>();
|
||||
int errorCount = 0, warnCount = 0;
|
||||
|
||||
// 预构建本地化缓存(整个验证过程只查询一次,避免大批量序列时重复读取本地化表)
|
||||
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;
|
||||
}
|
||||
|
||||
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++;
|
||||
}
|
||||
|
||||
// 1 & 2:空 / 重复 sequenceId
|
||||
var idMap = new System.Collections.Generic.Dictionary<string, DialogueSequenceSO>(System.StringComparer.Ordinal);
|
||||
var keyFormatRegex = new System.Text.RegularExpressions.Regex(@"^[\w\-\.]+$");
|
||||
foreach (var seq in allSeqs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(seq.sequenceId))
|
||||
{
|
||||
AddError($"{seq.name}: sequenceId 为空。", seq);
|
||||
continue;
|
||||
}
|
||||
if (idMap.TryGetValue(seq.sequenceId, out var existing))
|
||||
AddError($"重复 sequenceId \"{seq.sequenceId}\":{seq.name} 与 {existing.name}", seq);
|
||||
else
|
||||
idMap[seq.sequenceId] = seq;
|
||||
|
||||
// 2b. sequenceId 格式异常
|
||||
if (!keyFormatRegex.IsMatch(seq.sequenceId))
|
||||
AddWarn($"{seq.name}: sequenceId \"{seq.sequenceId}\" 含有空格或非法字符,建议只使用字母、数字、_、-、.。", seq);
|
||||
}
|
||||
|
||||
// 3-5:本地化 Key 存在性 + 格式检查
|
||||
foreach (var seq in allSeqs)
|
||||
{
|
||||
if (seq.lines == null) continue;
|
||||
for (int i = 0; i < seq.lines.Length; i++)
|
||||
{
|
||||
var line = seq.lines[i];
|
||||
string lineDesc = $"{seq.name} 行[{i}]";
|
||||
|
||||
// 文本 Key
|
||||
if (string.IsNullOrEmpty(line.textKey))
|
||||
{
|
||||
AddWarn($"{lineDesc}: textKey 为空,运行时显示空文本。", seq);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (GetLoc(line.textKey) == null)
|
||||
AddWarn($"{lineDesc}: textKey \"{line.textKey}\" 在本地化表中不存在。", seq);
|
||||
if (!keyFormatRegex.IsMatch(line.textKey))
|
||||
AddWarn($"{lineDesc}: textKey \"{line.textKey}\" 含有空格或非法字符。", seq);
|
||||
}
|
||||
|
||||
// 说话人 Key(无 actor 时检查直接字段)
|
||||
if (line.actor == null && !string.IsNullOrEmpty(line.speakerNameKey))
|
||||
{
|
||||
if (GetLoc(line.speakerNameKey) == null)
|
||||
AddWarn($"{lineDesc}: speakerNameKey \"{line.speakerNameKey}\" 在本地化表中不存在。", seq);
|
||||
if (!keyFormatRegex.IsMatch(line.speakerNameKey))
|
||||
AddWarn($"{lineDesc}: speakerNameKey \"{line.speakerNameKey}\" 含有空格或非法字符。", seq);
|
||||
}
|
||||
|
||||
// 选项 Key
|
||||
if (line.choices != null)
|
||||
{
|
||||
for (int j = 0; j < line.choices.Length; j++)
|
||||
{
|
||||
var choice = line.choices[j];
|
||||
if (string.IsNullOrEmpty(choice.textKey))
|
||||
{
|
||||
AddWarn($"{lineDesc} 选项[{j}]: textKey 为空。", seq);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (GetLoc(choice.textKey) == null)
|
||||
AddWarn($"{lineDesc} 选项[{j}]: textKey \"{choice.textKey}\" 在本地化表中不存在。", seq);
|
||||
if (!keyFormatRegex.IsMatch(choice.textKey))
|
||||
AddWarn($"{lineDesc} 选项[{j}]: textKey \"{choice.textKey}\" 含有空格或非法字符。", seq);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. variants[i].sequence 为 null(变体存在但序列引用为空)
|
||||
foreach (var seq in allSeqs)
|
||||
{
|
||||
if (seq.variants == null) continue;
|
||||
for (int vi = 0; vi < seq.variants.Length; vi++)
|
||||
{
|
||||
if (seq.variants[vi].sequence == null)
|
||||
AddWarn($"{seq.name}: variants[{vi}].sequence 为 null,满足条件时会回退默认序列(可能是未完成配置)。", seq);
|
||||
}
|
||||
}
|
||||
|
||||
Debug.Log($"[DialogueModule] 验证完成:{allSeqs.Count} 个序列,{errorCount} 个错误,{warnCount} 个警告。");
|
||||
QuestValidationResultWindow.Show(issues, errorCount, warnCount, allSeqs.Count, "对话批量验证结果", "序列");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Editor/Modules/DialogueModule.cs.meta
Normal file
11
Assets/_Game/Scripts/Editor/Modules/DialogueModule.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 76733bfe043064b4a980287067333483
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -10,7 +10,7 @@ namespace BaseGames.Editor.Modules
|
||||
/// <summary>
|
||||
/// DataHub 敌人模块 —— Tab 切换管理 EnemyStatsSO 和 LootTableSO。
|
||||
/// </summary>
|
||||
public class EnemyModule : IDataModule
|
||||
public class EnemyModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string StatsFolder = "Assets/_Game/Data/Enemies/Stats";
|
||||
private const string LootFolder = "Assets/_Game/Data/Enemies/Loot";
|
||||
@@ -18,6 +18,7 @@ namespace BaseGames.Editor.Modules
|
||||
public string ModuleId => "enemy";
|
||||
public string DisplayName => "敌人";
|
||||
public string IconName => null;
|
||||
public int DisplayOrder => 30;
|
||||
|
||||
private int _activeTab = 0; // 0=Stats, 1=Loot
|
||||
|
||||
|
||||
526
Assets/_Game/Scripts/Editor/Modules/EventChainModule.cs
Normal file
526
Assets/_Game/Scripts/Editor/Modules/EventChainModule.cs
Normal file
@@ -0,0 +1,526 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using BaseGames.EventChain;
|
||||
using BaseGames.Editor.Shared;
|
||||
|
||||
namespace BaseGames.Editor.Modules
|
||||
{
|
||||
/// <summary>
|
||||
/// DataHub 事件链模块 —— 管理 EventChainSO 资产。
|
||||
/// 支持浏览、创建、删除、预览条件/动作列表,以及批量验证。
|
||||
/// </summary>
|
||||
public class EventChainModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string Folder = "Assets/_Game/Data/EventChains";
|
||||
private const string Prefix = "Chain_";
|
||||
|
||||
public string ModuleId => "eventchain";
|
||||
public string DisplayName => "事件链";
|
||||
public string IconName => "d_Animation.Play";
|
||||
public int DisplayOrder => 120;
|
||||
|
||||
private SoListPane<EventChainSO> _listPane;
|
||||
private DetailHeader _header;
|
||||
private EventChainSO _selected;
|
||||
|
||||
// ── IDataModule ───────────────────────────────────────────────────────
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
_listPane = new SoListPane<EventChainSO>(
|
||||
Folder, Prefix,
|
||||
c => c.repeatable ? "可重复" : null);
|
||||
// 扩展搜索:chainId
|
||||
_listPane.GetExtraSearchText = c => c.chainId;
|
||||
}
|
||||
|
||||
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 filterRepeatable = false, filterNoCondition = false, filterNoAction = false;
|
||||
|
||||
void RebuildFilter()
|
||||
{
|
||||
if (!filterRepeatable && !filterNoCondition && !filterNoAction)
|
||||
{
|
||||
_listPane.ExtraFilter = null;
|
||||
return;
|
||||
}
|
||||
_listPane.ExtraFilter = c =>
|
||||
{
|
||||
if (filterRepeatable && !c.repeatable) return false;
|
||||
if (filterNoCondition && c.conditions != null && c.conditions.Length > 0) return false;
|
||||
if (filterNoAction && c.actions != null && c.actions.Length > 0) return false;
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
filterRow.Add(QuestModule.MakeFilterChip("可重复", v => { filterRepeatable = v; RebuildFilter(); }));
|
||||
filterRow.Add(QuestModule.MakeFilterChip("无条件", v => { filterNoCondition = v; RebuildFilter(); }));
|
||||
filterRow.Add(QuestModule.MakeFilterChip("无动作", v => { filterNoAction = v; RebuildFilter(); }));
|
||||
|
||||
container.Add(_listPane);
|
||||
_listPane.Refresh();
|
||||
}
|
||||
|
||||
public void BuildDetailPane(VisualElement container, UnityEngine.Object selected)
|
||||
{
|
||||
_selected = selected as EventChainSO;
|
||||
|
||||
_header = new DetailHeader();
|
||||
_header.SetAsset(_selected);
|
||||
_header.RenameRequested += OnRenameRequested;
|
||||
container.Add(_header);
|
||||
|
||||
if (_selected == null) return;
|
||||
|
||||
container.Add(BuildInfoCard(_selected));
|
||||
container.Add(BuildConditionsList(_selected));
|
||||
container.Add(BuildActionsList(_selected));
|
||||
container.Add(BuildActionBar(_selected));
|
||||
container.Add(SkillModule.MakeDivider());
|
||||
container.Add(new UnityEditor.UIElements.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(EventChainSO c)
|
||||
{
|
||||
var card = SkillModule.MakeCard();
|
||||
|
||||
SkillModule.AddChip(card, "Chain ID",
|
||||
string.IsNullOrEmpty(c.chainId) ? "(未设置)" : c.chainId);
|
||||
SkillModule.AddChip(card, "可重复", c.repeatable ? "是" : "否");
|
||||
|
||||
if (c.actionDelay > 0f)
|
||||
SkillModule.AddChip(card, "动作间隔", $"{c.actionDelay:F2}s");
|
||||
|
||||
int condCount = c.conditions?.Length ?? 0;
|
||||
int actCount = c.actions?.Length ?? 0;
|
||||
SkillModule.AddChip(card, "条件数量", condCount.ToString());
|
||||
SkillModule.AddChip(card, "动作数量", actCount.ToString());
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
private static VisualElement BuildConditionsList(EventChainSO c)
|
||||
{
|
||||
var section = new VisualElement();
|
||||
section.style.paddingLeft = 12;
|
||||
section.style.paddingRight = 12;
|
||||
section.style.paddingTop = 6;
|
||||
section.style.paddingBottom = 4;
|
||||
|
||||
var title = new Label("触发条件");
|
||||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
title.style.fontSize = 11;
|
||||
title.style.opacity = 0.8f;
|
||||
title.style.marginBottom = 4;
|
||||
section.Add(title);
|
||||
|
||||
bool hasGroups = c.conditionGroups != null && c.conditionGroups.Length > 0;
|
||||
bool hasLegacy = c.conditions != null && c.conditions.Length > 0;
|
||||
|
||||
if (!hasGroups && !hasLegacy)
|
||||
{
|
||||
var empty = new Label("(无条件,链将立即在首次 EvaluateAll 时触发)");
|
||||
empty.style.opacity = 0.5f;
|
||||
empty.style.fontSize = 11;
|
||||
section.Add(empty);
|
||||
return section;
|
||||
}
|
||||
|
||||
if (hasGroups)
|
||||
{
|
||||
// 新版 conditionGroups 展示
|
||||
for (int g = 0; g < c.conditionGroups.Length; g++)
|
||||
{
|
||||
var group = c.conditionGroups[g];
|
||||
|
||||
// 组标题
|
||||
var groupHeader = new VisualElement();
|
||||
groupHeader.style.flexDirection = FlexDirection.Row;
|
||||
groupHeader.style.alignItems = Align.Center;
|
||||
groupHeader.style.marginBottom = 2;
|
||||
groupHeader.style.marginTop = g > 0 ? 4 : 0;
|
||||
|
||||
var gLabel = new Label($"条件组 {g + 1}");
|
||||
gLabel.style.fontSize = 11;
|
||||
gLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
gLabel.style.flexGrow = 1;
|
||||
groupHeader.Add(gLabel);
|
||||
|
||||
var logicBadge = new Label(group.logic == BaseGames.Core.WorldStateFlagLogic.Or ? "Or(任一满足)" : "And(全部满足)");
|
||||
logicBadge.style.fontSize = 10;
|
||||
logicBadge.style.opacity = 0.6f;
|
||||
groupHeader.Add(logicBadge);
|
||||
section.Add(groupHeader);
|
||||
|
||||
if (group.conditions == null || group.conditions.Length == 0)
|
||||
{
|
||||
var noCondLabel = new Label(" (空组 — 无条件即视为满足)");
|
||||
noCondLabel.style.opacity = 0.45f;
|
||||
noCondLabel.style.fontSize = 11;
|
||||
section.Add(noCondLabel);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int i = 0; i < group.conditions.Length; i++)
|
||||
section.Add(BuildConditionRow(i, group.conditions[i], indent: true));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 旧版 conditions[](隐式 And)
|
||||
var legacyNote = new Label("(旧版条件列表,隐式 And 逻辑;建议迁移至 conditionGroups)");
|
||||
legacyNote.style.opacity = 0.45f;
|
||||
legacyNote.style.fontSize = 10;
|
||||
legacyNote.style.marginBottom = 2;
|
||||
section.Add(legacyNote);
|
||||
for (int i = 0; i < c.conditions.Length; i++)
|
||||
section.Add(BuildConditionRow(i, c.conditions[i], indent: false));
|
||||
}
|
||||
|
||||
// ── 运行时求值按钮(仅 Play Mode)──────────────────────────────
|
||||
if (Application.isPlaying)
|
||||
{
|
||||
section.Add(BuildRuntimeEvalPanel(c));
|
||||
}
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
private static VisualElement BuildConditionRow(int index, ChainCondition cond, bool indent)
|
||||
{
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.alignItems = Align.Center;
|
||||
row.style.marginBottom = 2;
|
||||
if (indent) row.style.paddingLeft = 12;
|
||||
|
||||
var idx = new Label($"{index + 1}.");
|
||||
idx.style.width = 18;
|
||||
idx.style.opacity = 0.5f;
|
||||
idx.style.fontSize = 11;
|
||||
row.Add(idx);
|
||||
|
||||
if (cond == null)
|
||||
{
|
||||
var warn = new Label("⚠ null(Inspector 中有空槽,请检查)");
|
||||
warn.style.color = new StyleColor(new Color(0.9f, 0.5f, 0.2f));
|
||||
warn.style.fontSize = 11;
|
||||
row.Add(warn);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 运行时:直接显示 IsMet() 结果
|
||||
if (Application.isPlaying)
|
||||
{
|
||||
bool met = cond.IsMet();
|
||||
var statusIcon = new Label(met ? "✓" : "✗");
|
||||
statusIcon.style.fontSize = 11;
|
||||
statusIcon.style.width = 14;
|
||||
statusIcon.style.color = new StyleColor(met
|
||||
? new Color(0.2f, 0.75f, 0.35f)
|
||||
: new Color(0.85f, 0.35f, 0.30f));
|
||||
row.Add(statusIcon);
|
||||
}
|
||||
|
||||
var typeName = new Label(cond.GetType().Name);
|
||||
typeName.style.flexGrow = 1;
|
||||
typeName.style.fontSize = 11;
|
||||
row.Add(typeName);
|
||||
|
||||
var ping = new Button(() => { EditorGUIUtility.PingObject(cond); Selection.activeObject = cond; })
|
||||
{ text = "定位" };
|
||||
ping.style.fontSize = 10;
|
||||
ping.style.height = 18;
|
||||
row.Add(ping);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
private static VisualElement BuildRuntimeEvalPanel(EventChainSO c)
|
||||
{
|
||||
var panel = new VisualElement();
|
||||
panel.style.marginTop = 6;
|
||||
panel.style.paddingTop = 4;
|
||||
panel.style.paddingBottom = 4;
|
||||
panel.style.paddingLeft = 8;
|
||||
panel.style.paddingRight = 8;
|
||||
panel.style.backgroundColor = new StyleColor(new Color(0.15f, 0.22f, 0.15f, 1f));
|
||||
|
||||
var header = new VisualElement();
|
||||
header.style.flexDirection = FlexDirection.Row;
|
||||
header.style.alignItems = Align.Center;
|
||||
header.style.marginBottom = 2;
|
||||
|
||||
var title = new Label("▶ 运行时状态");
|
||||
title.style.fontSize = 10;
|
||||
title.style.opacity = 0.7f;
|
||||
title.style.flexGrow = 1;
|
||||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
header.Add(title);
|
||||
|
||||
var resultLabel = new Label();
|
||||
resultLabel.style.fontSize = 10;
|
||||
|
||||
void RefreshEval()
|
||||
{
|
||||
// 简单逐条 IsMet 求值用于显示(不触发实际执行)
|
||||
bool hasGroups = c.conditionGroups != null && c.conditionGroups.Length > 0;
|
||||
bool allPass = true;
|
||||
if (hasGroups)
|
||||
{
|
||||
foreach (var g in c.conditionGroups)
|
||||
{
|
||||
if (g.conditions == null || g.conditions.Length == 0) continue;
|
||||
bool groupPass = g.logic == BaseGames.Core.WorldStateFlagLogic.Or
|
||||
? System.Array.Exists(g.conditions, x => x != null && x.IsMet())
|
||||
: System.Array.TrueForAll(g.conditions, x => x == null || x.IsMet());
|
||||
if (!groupPass) { allPass = false; break; }
|
||||
}
|
||||
}
|
||||
else if (c.conditions != null)
|
||||
{
|
||||
allPass = System.Array.TrueForAll(c.conditions, x => x == null || x.IsMet());
|
||||
}
|
||||
|
||||
resultLabel.text = allPass ? "✓ 当前满足触发条件" : "✗ 当前不满足触发条件";
|
||||
resultLabel.style.color = new StyleColor(allPass
|
||||
? new Color(0.2f, 0.75f, 0.35f)
|
||||
: new Color(0.85f, 0.35f, 0.30f));
|
||||
}
|
||||
|
||||
RefreshEval();
|
||||
|
||||
var refreshBtn = new Button(RefreshEval) { text = "刷新" };
|
||||
refreshBtn.style.fontSize = 9;
|
||||
refreshBtn.style.height = 16;
|
||||
header.Add(refreshBtn);
|
||||
|
||||
panel.Add(header);
|
||||
panel.Add(resultLabel);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private static VisualElement BuildActionsList(EventChainSO c)
|
||||
{
|
||||
var section = new VisualElement();
|
||||
section.style.paddingLeft = 12;
|
||||
section.style.paddingRight = 12;
|
||||
section.style.paddingTop = 6;
|
||||
section.style.paddingBottom = 4;
|
||||
|
||||
var title = new Label("执行动作");
|
||||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
title.style.fontSize = 11;
|
||||
title.style.opacity = 0.8f;
|
||||
title.style.marginBottom = 4;
|
||||
section.Add(title);
|
||||
|
||||
if (c.actions == null || c.actions.Length == 0)
|
||||
{
|
||||
var empty = new Label("(无动作,链触发后不执行任何操作)");
|
||||
empty.style.opacity = 0.5f;
|
||||
empty.style.fontSize = 11;
|
||||
section.Add(empty);
|
||||
return section;
|
||||
}
|
||||
|
||||
for (int i = 0; i < c.actions.Length; i++)
|
||||
{
|
||||
var act = c.actions[i];
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.alignItems = Align.Center;
|
||||
row.style.marginBottom = 2;
|
||||
|
||||
var idx = new Label($"{i + 1}.");
|
||||
idx.style.width = 18;
|
||||
idx.style.opacity = 0.5f;
|
||||
idx.style.fontSize = 11;
|
||||
row.Add(idx);
|
||||
|
||||
if (act == null)
|
||||
{
|
||||
var warn = new Label("⚠ null(Inspector 中有空槽,请检查)");
|
||||
warn.style.color = new StyleColor(new Color(0.9f, 0.5f, 0.2f));
|
||||
warn.style.fontSize = 11;
|
||||
row.Add(warn);
|
||||
}
|
||||
else
|
||||
{
|
||||
var typeName = new Label($"{act.GetType().Name} {act.name}");
|
||||
typeName.style.flexGrow = 1;
|
||||
typeName.style.fontSize = 11;
|
||||
row.Add(typeName);
|
||||
|
||||
var ping = new Button(() => { EditorGUIUtility.PingObject(act); Selection.activeObject = act; })
|
||||
{ text = "定位" };
|
||||
ping.style.fontSize = 10;
|
||||
ping.style.height = 18;
|
||||
row.Add(ping);
|
||||
}
|
||||
|
||||
section.Add(row);
|
||||
}
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
private VisualElement BuildActionBar(EventChainSO c)
|
||||
{
|
||||
var bar = SkillModule.MakeActionBar();
|
||||
|
||||
new Button(() =>
|
||||
{
|
||||
AssetCreationWizard.Show<EventChainSO>(Folder, Prefix, (asset, id) =>
|
||||
{
|
||||
asset.chainId = id;
|
||||
EditorUtility.SetDirty(asset);
|
||||
AssetDatabase.SaveAssets();
|
||||
_listPane.Refresh(asset);
|
||||
});
|
||||
}) { text = "创建" }.AlsoAddTo(bar);
|
||||
|
||||
new Button(() => { EditorGUIUtility.PingObject(c); Selection.activeObject = c; })
|
||||
{ text = "定位" }.AlsoAddTo(bar);
|
||||
|
||||
new Button(() =>
|
||||
{
|
||||
string path = AssetDatabase.GetAssetPath(c);
|
||||
if (!string.IsNullOrEmpty(path)) EditorGUIUtility.systemCopyBuffer = path;
|
||||
}) { text = "复制路径" }.AlsoAddTo(bar);
|
||||
|
||||
new Button(ValidateAllChains) { text = "批量验证" }.AlsoAddTo(bar);
|
||||
|
||||
// 强制触发按钮仅在 Play Mode 下显示,用于调试绕过条件检查
|
||||
if (Application.isPlaying)
|
||||
{
|
||||
var capturedC = c;
|
||||
var forceBtn = new Button(() =>
|
||||
{
|
||||
var mgr = UnityEngine.Object.FindObjectOfType<EventChainManager>();
|
||||
if (mgr == null)
|
||||
{
|
||||
Debug.LogWarning("[EventChainModule] 场景中未找到 EventChainManager,无法强制触发。");
|
||||
return;
|
||||
}
|
||||
Debug.Log($"[EventChainModule] 强制触发链:{capturedC.chainId}");
|
||||
mgr.ForceExecute(capturedC.chainId);
|
||||
}) { text = "⚡ 强制触发" };
|
||||
forceBtn.style.color = new StyleColor(new Color(1f, 0.75f, 0.2f));
|
||||
forceBtn.AlsoAddTo(bar);
|
||||
}
|
||||
|
||||
var del = new Button(() =>
|
||||
{
|
||||
if (AssetOperations.Delete(c)) _listPane.Refresh(null);
|
||||
}) { text = "删除" };
|
||||
SkillModule.ApplyDeleteStyle(del);
|
||||
del.AlsoAddTo(bar);
|
||||
|
||||
return bar;
|
||||
}
|
||||
|
||||
// ── 批量验证 ──────────────────────────────────────────────────────────
|
||||
|
||||
private static void ValidateAllChains()
|
||||
{
|
||||
var allChains = AssetOperations.FindAll<EventChainSO>();
|
||||
if (allChains.Count == 0)
|
||||
{
|
||||
EditorUtility.DisplayDialog("事件链验证", "项目中未找到任何 EventChainSO。", "确定");
|
||||
return;
|
||||
}
|
||||
|
||||
var issues = new List<QuestValidationResultWindow.Issue>();
|
||||
int errorCount = 0, warnCount = 0;
|
||||
|
||||
void AddError(string msg, UnityEngine.Object asset)
|
||||
{
|
||||
issues.Add(new QuestValidationResultWindow.Issue { message = msg, isError = true, asset = asset });
|
||||
errorCount++;
|
||||
}
|
||||
void AddWarn(string msg, UnityEngine.Object asset)
|
||||
{
|
||||
issues.Add(new QuestValidationResultWindow.Issue { message = msg, isError = false, asset = asset });
|
||||
warnCount++;
|
||||
}
|
||||
|
||||
// ① 空 chainId
|
||||
foreach (var c in allChains)
|
||||
if (string.IsNullOrWhiteSpace(c.chainId))
|
||||
AddError($"{c.name}: chainId 为空,运行时无法存档或被 ChainCompletedCondition 引用。", c);
|
||||
|
||||
// ② 重复 chainId
|
||||
var idMap = new Dictionary<string, EventChainSO>(StringComparer.Ordinal);
|
||||
foreach (var c in allChains)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(c.chainId)) continue;
|
||||
if (!idMap.TryAdd(c.chainId, c))
|
||||
AddError($"chainId '{c.chainId}' 重复({idMap[c.chainId].name} 与 {c.name}),运行时存档键将互串。", c);
|
||||
}
|
||||
|
||||
// ③ 无动作
|
||||
foreach (var c in allChains)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(c.chainId)) continue;
|
||||
if (c.actions == null || c.actions.Length == 0)
|
||||
AddWarn($"{c.chainId}: actions 为空,链触发后不执行任何操作。", c);
|
||||
}
|
||||
|
||||
// ④ conditions 含空槽
|
||||
foreach (var c in allChains)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(c.chainId) || c.conditions == null) continue;
|
||||
for (int i = 0; i < c.conditions.Length; i++)
|
||||
if (c.conditions[i] == null)
|
||||
AddError($"{c.chainId}: conditions[{i}] 为 null,运行时将触发 NullReferenceException。", c);
|
||||
}
|
||||
|
||||
// ⑤ actions 含空槽
|
||||
foreach (var c in allChains)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(c.chainId) || c.actions == null) continue;
|
||||
for (int i = 0; i < c.actions.Length; i++)
|
||||
if (c.actions[i] == null)
|
||||
AddError($"{c.chainId}: actions[{i}] 为 null,运行时将触发 NullReferenceException。", c);
|
||||
}
|
||||
|
||||
Debug.Log($"[EventChainModule] 验证完成:{allChains.Count} 条事件链,{errorCount} 个错误,{warnCount} 个警告。");
|
||||
QuestValidationResultWindow.Show(issues, errorCount, warnCount, allChains.Count, "事件链批量验证结果", "事件链");
|
||||
}
|
||||
}
|
||||
}
|
||||
508
Assets/_Game/Scripts/Editor/Modules/FlagAuditModule.cs
Normal file
508
Assets/_Game/Scripts/Editor/Modules/FlagAuditModule.cs
Normal file
@@ -0,0 +1,508 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Dialogue;
|
||||
using BaseGames.Quest;
|
||||
using BaseGames.EventChain;
|
||||
using BaseGames.Editor;
|
||||
|
||||
namespace BaseGames.Editor.Modules
|
||||
{
|
||||
/// <summary>
|
||||
/// DataHub 标志审计模块 —— 扫描项目所有 WorldStateFlag 引用,
|
||||
/// 检测孤立标志(已注册但从未使用)和未注册标志(已使用但未在注册表定义)。
|
||||
/// </summary>
|
||||
public class FlagAuditModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
public string ModuleId => "flagaudit";
|
||||
public string DisplayName => "标志审计";
|
||||
public string IconName => "d_FilterByLabel";
|
||||
public int DisplayOrder => 130;
|
||||
|
||||
// ── 数据 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private readonly List<FlagRecord> _records = new();
|
||||
private FlagRecord _selected;
|
||||
private bool _hasScanned;
|
||||
|
||||
private class FlagRecord
|
||||
{
|
||||
public string id;
|
||||
public string description;
|
||||
public string group;
|
||||
public bool isRegistered;
|
||||
public readonly List<(string label, UnityEngine.Object asset)> setLocations = new();
|
||||
public readonly List<(string label, UnityEngine.Object asset)> readLocations = new();
|
||||
|
||||
public bool IsOrphan => isRegistered && TotalUsages == 0;
|
||||
public bool IsUnregistered => !isRegistered;
|
||||
public int TotalUsages => setLocations.Count + readLocations.Count;
|
||||
}
|
||||
|
||||
// ── UI 引用 ───────────────────────────────────────────────────────────
|
||||
|
||||
private VisualElement _listItems;
|
||||
private Label _summaryLabel;
|
||||
private VisualElement _detailRoot;
|
||||
private bool _filterOrphan, _filterUnregistered;
|
||||
|
||||
// ── IDataModule ───────────────────────────────────────────────────────
|
||||
|
||||
public void Initialize() { }
|
||||
|
||||
public void BuildListPane(VisualElement container, Action<UnityEngine.Object> onSelected)
|
||||
{
|
||||
// 扫描按钮
|
||||
var scanBtn = new Button(RunScan) { text = "🔍 扫描标志使用情况" };
|
||||
scanBtn.style.marginTop = 8;
|
||||
scanBtn.style.marginLeft = 8;
|
||||
scanBtn.style.marginRight = 8;
|
||||
scanBtn.style.marginBottom = 4;
|
||||
container.Add(scanBtn);
|
||||
|
||||
// 统计行
|
||||
_summaryLabel = new Label("尚未扫描,点击上方按钮开始。");
|
||||
_summaryLabel.style.fontSize = 10;
|
||||
_summaryLabel.style.opacity = 0.6f;
|
||||
_summaryLabel.style.paddingLeft = 10;
|
||||
_summaryLabel.style.marginBottom = 4;
|
||||
container.Add(_summaryLabel);
|
||||
|
||||
// 过滤标签行
|
||||
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);
|
||||
|
||||
filterRow.Add(QuestModule.MakeFilterChip("仅孤立", v => { _filterOrphan = v; RebuildList(); }));
|
||||
filterRow.Add(QuestModule.MakeFilterChip("仅未注册", v => { _filterUnregistered = v; RebuildList(); }));
|
||||
|
||||
// 列表 ScrollView
|
||||
var scroll = new ScrollView();
|
||||
scroll.style.flexGrow = 1;
|
||||
container.Add(scroll);
|
||||
|
||||
_listItems = new VisualElement();
|
||||
scroll.Add(_listItems);
|
||||
}
|
||||
|
||||
public void BuildDetailPane(VisualElement container, UnityEngine.Object selected)
|
||||
{
|
||||
_detailRoot = container;
|
||||
RebuildDetail();
|
||||
}
|
||||
|
||||
public void OnActivated() { }
|
||||
|
||||
// ── 扫描 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void RunScan()
|
||||
{
|
||||
_records.Clear();
|
||||
_hasScanned = true;
|
||||
|
||||
var byId = new Dictionary<string, FlagRecord>(StringComparer.Ordinal);
|
||||
FlagRecord GetOrCreate(string id)
|
||||
{
|
||||
if (!byId.TryGetValue(id, out var r))
|
||||
{
|
||||
r = new FlagRecord { id = id };
|
||||
byId[id] = r;
|
||||
_records.Add(r);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
// 1. 从 WorldFlagRegistrySO 导入注册表
|
||||
var registry = WorldFlagRegistrySO.EditorInstance;
|
||||
if (registry?.flags != null)
|
||||
foreach (var entry in registry.flags)
|
||||
{
|
||||
if (string.IsNullOrEmpty(entry.id)) continue;
|
||||
var r = GetOrCreate(entry.id);
|
||||
r.isRegistered = true;
|
||||
r.description = entry.description;
|
||||
r.group = entry.group;
|
||||
}
|
||||
|
||||
// 2. 扫描 DialogueSequenceSO
|
||||
foreach (var seq in AssetOperations.FindAll<DialogueSequenceSO>())
|
||||
{
|
||||
// variants[i].requiredFlags → 读取
|
||||
if (seq.variants != null)
|
||||
foreach (var v in seq.variants)
|
||||
if (v.requiredFlags != null)
|
||||
foreach (var fid in v.requiredFlags)
|
||||
if (!string.IsNullOrEmpty(fid))
|
||||
GetOrCreate(fid).readLocations.Add(($"对话变体条件 [{seq.name}]", seq));
|
||||
|
||||
// lines[i].choices[j].setWorldFlag → 设置
|
||||
if (seq.lines != null)
|
||||
foreach (var line in seq.lines)
|
||||
if (line.choices != null)
|
||||
foreach (var ch in line.choices)
|
||||
if (!string.IsNullOrEmpty(ch.setWorldFlag))
|
||||
GetOrCreate(ch.setWorldFlag).setLocations.Add(($"对话选项设置 [{seq.name}]", seq));
|
||||
}
|
||||
|
||||
// 3. 扫描 QuestSO
|
||||
foreach (var quest in AssetOperations.FindAll<QuestSO>())
|
||||
{
|
||||
// branches[i].conditionFlags → 读取
|
||||
if (quest.branches != null)
|
||||
foreach (var branch in quest.branches)
|
||||
if (branch.conditionFlags != null)
|
||||
foreach (var fid in branch.conditionFlags)
|
||||
if (!string.IsNullOrEmpty(fid))
|
||||
GetOrCreate(fid).readLocations.Add(($"任务分支条件 [{quest.name}]", quest));
|
||||
|
||||
// prerequisiteFlags → 读取
|
||||
if (quest.prerequisiteFlags != null)
|
||||
foreach (var fid in quest.prerequisiteFlags)
|
||||
if (!string.IsNullOrEmpty(fid))
|
||||
GetOrCreate(fid).readLocations.Add(($"任务前置标志 [{quest.name}]", quest));
|
||||
}
|
||||
|
||||
// 4. 扫描 FlagSetCondition(EventChain 条件)→ 读取
|
||||
foreach (var cond in AssetOperations.FindAll<FlagSetCondition>())
|
||||
if (!string.IsNullOrEmpty(cond.flagId))
|
||||
GetOrCreate(cond.flagId).readLocations.Add(($"链条件 [{cond.name}]", cond));
|
||||
|
||||
// 5. 扫描 SetFlagAction(EventChain 动作)→ 设置
|
||||
foreach (var act in AssetOperations.FindAll<SetFlagAction>())
|
||||
if (!string.IsNullOrEmpty(act.flagId))
|
||||
GetOrCreate(act.flagId).setLocations.Add(($"链动作 [{act.name}]", act));
|
||||
|
||||
// 6. 扫描 NarrativeNPC 预制件中的 DialogueVersion 条件标志
|
||||
// NarrativeNPC 是 MonoBehaviour,使用 SerializedObject 读取序列化字段以避免反射。
|
||||
var prefabGuids = AssetDatabase.FindAssets("t:Prefab", new[] { "Assets/_Game" });
|
||||
foreach (var guid in prefabGuids)
|
||||
{
|
||||
string path = AssetDatabase.GUIDToAssetPath(guid);
|
||||
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path);
|
||||
if (prefab == null) continue;
|
||||
foreach (var npc in prefab.GetComponentsInChildren<NarrativeNPC>(true))
|
||||
{
|
||||
var so = new SerializedObject(npc);
|
||||
var vProp = so.FindProperty("_dialogueVersions");
|
||||
if (vProp == null || !vProp.isArray) continue;
|
||||
for (int i = 0; i < vProp.arraySize; i++)
|
||||
{
|
||||
var elem = vProp.GetArrayElementAtIndex(i);
|
||||
var reqProp = elem.FindPropertyRelative("requiredFlags");
|
||||
var blockProp = elem.FindPropertyRelative("blockedByFlags");
|
||||
if (reqProp != null && reqProp.isArray)
|
||||
for (int j = 0; j < reqProp.arraySize; j++)
|
||||
{
|
||||
string fid = reqProp.GetArrayElementAtIndex(j).stringValue;
|
||||
if (!string.IsNullOrEmpty(fid))
|
||||
GetOrCreate(fid).readLocations.Add(($"NPC版本条件 [{prefab.name}]", prefab));
|
||||
}
|
||||
if (blockProp != null && blockProp.isArray)
|
||||
for (int j = 0; j < blockProp.arraySize; j++)
|
||||
{
|
||||
string fid = blockProp.GetArrayElementAtIndex(j).stringValue;
|
||||
if (!string.IsNullOrEmpty(fid))
|
||||
GetOrCreate(fid).readLocations.Add(($"NPC版本屏蔽 [{prefab.name}]", prefab));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 排序:未注册 → 孤立 → 正常,再按 ID 字典序
|
||||
_records.Sort((a, b) =>
|
||||
{
|
||||
int pa = a.IsUnregistered ? 0 : a.IsOrphan ? 1 : 2;
|
||||
int pb = b.IsUnregistered ? 0 : b.IsOrphan ? 1 : 2;
|
||||
int c = pa.CompareTo(pb);
|
||||
return c != 0 ? c : string.Compare(a.id, b.id, StringComparison.Ordinal);
|
||||
});
|
||||
|
||||
RebuildList();
|
||||
RebuildDetail();
|
||||
}
|
||||
|
||||
// ── 列表重建 ─────────────────────────────────────────────────────────
|
||||
|
||||
private void RebuildList()
|
||||
{
|
||||
if (_listItems == null) return;
|
||||
_listItems.Clear();
|
||||
if (!_hasScanned) return;
|
||||
|
||||
int total = _records.Count;
|
||||
int orphanCount = _records.Count(r => r.IsOrphan);
|
||||
int unregCount = _records.Count(r => r.IsUnregistered);
|
||||
if (_summaryLabel != null)
|
||||
_summaryLabel.text = $"共 {total} 个标志 · 孤立 {orphanCount} · 未注册 {unregCount}";
|
||||
|
||||
foreach (var rec in _records)
|
||||
{
|
||||
if (_filterOrphan && !rec.IsOrphan) continue;
|
||||
if (_filterUnregistered && !rec.IsUnregistered) continue;
|
||||
|
||||
bool isSelected = rec == _selected;
|
||||
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.alignItems = Align.Center;
|
||||
row.style.paddingTop = 3;
|
||||
row.style.paddingBottom = 3;
|
||||
row.style.paddingLeft = 8;
|
||||
row.style.paddingRight = 8;
|
||||
row.style.backgroundColor = isSelected
|
||||
? new StyleColor(new Color(0.25f, 0.5f, 1f, 0.2f))
|
||||
: StyleKeyword.None;
|
||||
|
||||
// 状态图标 + 颜色
|
||||
string icon = rec.IsUnregistered ? "⚠" : rec.IsOrphan ? "○" : "●";
|
||||
Color iconColor = rec.IsUnregistered
|
||||
? new Color(1f, 0.4f, 0.2f)
|
||||
: rec.IsOrphan
|
||||
? new Color(1f, 0.85f, 0.1f)
|
||||
: new Color(0.4f, 0.85f, 0.4f);
|
||||
|
||||
var iconLbl = new Label(icon);
|
||||
iconLbl.style.fontSize = 10;
|
||||
iconLbl.style.color = new StyleColor(iconColor);
|
||||
iconLbl.style.width = 14;
|
||||
iconLbl.style.flexShrink = 0;
|
||||
row.Add(iconLbl);
|
||||
|
||||
var idLbl = new Label(rec.id);
|
||||
idLbl.style.fontSize = 11;
|
||||
idLbl.style.flexGrow = 1;
|
||||
row.Add(idLbl);
|
||||
|
||||
// 使用次数徽章
|
||||
if (rec.TotalUsages > 0)
|
||||
{
|
||||
var badge = new Label(rec.TotalUsages.ToString());
|
||||
badge.style.fontSize = 9;
|
||||
badge.style.opacity = 0.6f;
|
||||
badge.style.paddingLeft = 4;
|
||||
badge.style.paddingRight = 4;
|
||||
badge.style.paddingTop = 1;
|
||||
badge.style.paddingBottom = 1;
|
||||
badge.style.borderTopLeftRadius = 8;
|
||||
badge.style.borderTopRightRadius = 8;
|
||||
badge.style.borderBottomLeftRadius = 8;
|
||||
badge.style.borderBottomRightRadius = 8;
|
||||
badge.style.backgroundColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.25f));
|
||||
row.Add(badge);
|
||||
}
|
||||
|
||||
var capturedRec = rec;
|
||||
row.RegisterCallback<ClickEvent>(_ =>
|
||||
{
|
||||
_selected = capturedRec;
|
||||
RebuildList();
|
||||
RebuildDetail();
|
||||
});
|
||||
|
||||
_listItems.Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 详情重建 ─────────────────────────────────────────────────────────
|
||||
|
||||
private void RebuildDetail()
|
||||
{
|
||||
if (_detailRoot == null) return;
|
||||
_detailRoot.Clear();
|
||||
|
||||
if (!_hasScanned)
|
||||
{
|
||||
var hint = new Label("请先点击「扫描标志使用情况」按钮。");
|
||||
hint.style.opacity = 0.5f;
|
||||
hint.style.marginTop = 24;
|
||||
hint.style.unityTextAlign = TextAnchor.UpperCenter;
|
||||
_detailRoot.Add(hint);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_selected == null)
|
||||
{
|
||||
var hint = new Label("← 从左侧选择一个标志查看详情。");
|
||||
hint.style.opacity = 0.5f;
|
||||
hint.style.marginTop = 24;
|
||||
hint.style.unityTextAlign = TextAnchor.UpperCenter;
|
||||
_detailRoot.Add(hint);
|
||||
return;
|
||||
}
|
||||
|
||||
var r = _selected;
|
||||
|
||||
// 标题
|
||||
var titleLbl = new Label(r.id);
|
||||
titleLbl.style.fontSize = 15;
|
||||
titleLbl.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
titleLbl.style.paddingLeft = 12;
|
||||
titleLbl.style.paddingTop = 12;
|
||||
titleLbl.style.paddingBottom = 2;
|
||||
_detailRoot.Add(titleLbl);
|
||||
|
||||
// 状态徽章
|
||||
string statusText = r.IsUnregistered ? "⚠ 未在注册表中定义" : r.IsOrphan ? "○ 已注册但从未使用(孤立)" : "● 正常";
|
||||
Color statusColor = r.IsUnregistered ? new Color(1f, 0.4f, 0.2f) : r.IsOrphan ? new Color(1f, 0.85f, 0.1f) : new Color(0.4f, 0.85f, 0.4f);
|
||||
var statusLbl = new Label(statusText);
|
||||
statusLbl.style.fontSize = 11;
|
||||
statusLbl.style.color = new StyleColor(statusColor);
|
||||
statusLbl.style.paddingLeft = 12;
|
||||
statusLbl.style.marginBottom = 4;
|
||||
_detailRoot.Add(statusLbl);
|
||||
|
||||
// "注册到注册表" 快捷按钮(仅未注册标志显示)
|
||||
if (r.IsUnregistered)
|
||||
{
|
||||
var capturedRec = r;
|
||||
var regBtn = new Button(() => RegisterFlagToRegistry(capturedRec))
|
||||
{
|
||||
text = "+ 注册到注册表",
|
||||
tooltip = "将此标志 ID 追加到 WorldFlagRegistrySO.flags[] 中,并重新扫描。",
|
||||
};
|
||||
regBtn.style.marginLeft = 10;
|
||||
regBtn.style.marginBottom = 6;
|
||||
regBtn.style.width = 130;
|
||||
_detailRoot.Add(regBtn);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(r.group)) AddDetailRow("分组", r.group);
|
||||
if (!string.IsNullOrEmpty(r.description)) AddDetailRow("描述", r.description);
|
||||
|
||||
_detailRoot.Add(SkillModule.MakeDivider());
|
||||
|
||||
AddLocationSection("📝 设置位置", r.setLocations, "无设置记录(标志只被读取,从不被写入)");
|
||||
AddLocationSection("🔎 读取位置", r.readLocations, "无读取记录(标志只被写入,从不被读取)");
|
||||
}
|
||||
|
||||
private void AddDetailRow(string label, string value)
|
||||
{
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.paddingLeft = 12;
|
||||
row.style.paddingBottom = 2;
|
||||
|
||||
var lbl = new Label($"{label}:");
|
||||
lbl.style.fontSize = 11;
|
||||
lbl.style.opacity = 0.55f;
|
||||
lbl.style.width = 48;
|
||||
lbl.style.flexShrink = 0;
|
||||
|
||||
var val = new Label(value);
|
||||
val.style.fontSize = 11;
|
||||
val.style.flexGrow = 1;
|
||||
val.style.flexWrap = Wrap.Wrap;
|
||||
|
||||
row.Add(lbl);
|
||||
row.Add(val);
|
||||
_detailRoot.Add(row);
|
||||
}
|
||||
|
||||
private void AddLocationSection(string sectionTitle, List<(string label, UnityEngine.Object asset)> locations, string emptyText)
|
||||
{
|
||||
var header = new Label(sectionTitle);
|
||||
header.style.fontSize = 11;
|
||||
header.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
header.style.paddingLeft = 12;
|
||||
header.style.paddingTop = 8;
|
||||
header.style.paddingBottom = 3;
|
||||
_detailRoot.Add(header);
|
||||
|
||||
if (locations.Count == 0)
|
||||
{
|
||||
var empty = new Label(emptyText);
|
||||
empty.style.fontSize = 10;
|
||||
empty.style.opacity = 0.45f;
|
||||
empty.style.paddingLeft = 20;
|
||||
empty.style.marginBottom = 4;
|
||||
_detailRoot.Add(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (lbl, asset) in locations)
|
||||
{
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.alignItems = Align.Center;
|
||||
row.style.paddingLeft = 14;
|
||||
row.style.paddingRight = 8;
|
||||
row.style.marginBottom = 2;
|
||||
|
||||
var caption = new Label(lbl);
|
||||
caption.style.fontSize = 11;
|
||||
caption.style.flexGrow = 1;
|
||||
row.Add(caption);
|
||||
|
||||
if (asset != null)
|
||||
{
|
||||
var pingBtn = new Button(() =>
|
||||
{
|
||||
EditorGUIUtility.PingObject(asset);
|
||||
Selection.activeObject = asset;
|
||||
}) { text = "选中" };
|
||||
pingBtn.style.fontSize = 10;
|
||||
pingBtn.style.width = 36;
|
||||
pingBtn.style.height = 18;
|
||||
pingBtn.style.paddingTop = 0;
|
||||
pingBtn.style.paddingBottom = 0;
|
||||
row.Add(pingBtn);
|
||||
}
|
||||
|
||||
_detailRoot.Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 注册快捷操作 ──────────────────────────────────────────────────────
|
||||
|
||||
private void RegisterFlagToRegistry(FlagRecord rec)
|
||||
{
|
||||
var registry = WorldFlagRegistrySO.EditorInstance;
|
||||
if (registry == null)
|
||||
{
|
||||
EditorUtility.DisplayDialog(
|
||||
"注册表不存在",
|
||||
"项目中未找到 WorldFlagRegistrySO 资产。\n" +
|
||||
"请先通过 Create → BaseGames/Core/WorldFlagRegistry 创建注册表。",
|
||||
"确定");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已存在(理论上不可能,但防御性检查)
|
||||
if (registry.flags != null)
|
||||
{
|
||||
foreach (var entry in registry.flags)
|
||||
if (entry.id == rec.id) return;
|
||||
}
|
||||
|
||||
var newEntry = new FlagEntry
|
||||
{
|
||||
id = rec.id,
|
||||
description = "",
|
||||
group = "",
|
||||
};
|
||||
|
||||
var flags = registry.flags ?? System.Array.Empty<FlagEntry>();
|
||||
var list = new System.Collections.Generic.List<FlagEntry>(flags) { newEntry };
|
||||
Undo.RegisterCompleteObjectUndo(registry, $"注册标志 {rec.id}");
|
||||
registry.flags = list.ToArray();
|
||||
|
||||
EditorUtility.SetDirty(registry);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
// 将记录标记为已注册并重建 UI
|
||||
rec.isRegistered = true;
|
||||
RebuildList();
|
||||
RebuildDetail();
|
||||
|
||||
Debug.Log($"[FlagAuditModule] 已将标志 '{rec.id}' 注册到 WorldFlagRegistrySO。");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ namespace BaseGames.Editor.Modules
|
||||
/// <summary>
|
||||
/// DataHub 形态模块 —— Tab 切换管理 FormConfigSO 和 FormSO 资产。
|
||||
/// </summary>
|
||||
public class FormModule : IDataModule
|
||||
public class FormModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string ConfigFolder = "Assets/_Game/Data/Player/Forms";
|
||||
private const string FormFolder = "Assets/_Game/Data/Player/Forms";
|
||||
@@ -18,6 +18,7 @@ namespace BaseGames.Editor.Modules
|
||||
public string ModuleId => "form";
|
||||
public string DisplayName => "形态";
|
||||
public string IconName => null;
|
||||
public int DisplayOrder => 40;
|
||||
|
||||
private int _activeTab = 0; // 0=FormConfig, 1=FormSO
|
||||
|
||||
|
||||
197
Assets/_Game/Scripts/Editor/Modules/IdCodegenModule.cs
Normal file
197
Assets/_Game/Scripts/Editor/Modules/IdCodegenModule.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using BaseGames.Dialogue;
|
||||
using BaseGames.Quest;
|
||||
using BaseGames.EventChain;
|
||||
|
||||
namespace BaseGames.Editor.Modules
|
||||
{
|
||||
/// <summary>
|
||||
/// DataHub ID 生成模块 —— 扫描 QuestSO / NpcSO / DialogueSequenceSO / EventChainSO 资产,
|
||||
/// 自动生成 <c>Assets/_Game/Scripts/Core/GameIds.Generated.cs</c>,
|
||||
/// 提供编译期 ID 常量,消除代码中的魔法字符串。
|
||||
/// </summary>
|
||||
public class IdCodegenModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string OutputPath = "Assets/_Game/Scripts/Core/GameIds.Generated.cs";
|
||||
|
||||
public string ModuleId => "idcodegen";
|
||||
public string DisplayName => "ID 生成";
|
||||
public string IconName => "d_cs Script Icon";
|
||||
public int DisplayOrder => 140;
|
||||
|
||||
private Label _statusLabel;
|
||||
private string _lastResult;
|
||||
|
||||
// ── IDataModule ───────────────────────────────────────────────────────
|
||||
|
||||
public void Initialize() { }
|
||||
|
||||
public void BuildListPane(VisualElement container, Action<UnityEngine.Object> onSelected)
|
||||
{
|
||||
var desc = new Label(
|
||||
"扫描项目中的 QuestSO / NpcSO / DialogueSequenceSO / EventChainSO 资产,\n" +
|
||||
$"生成 {OutputPath} 常量文件。\n\n" +
|
||||
"生成后在代码中通过 GameIdsGenerated.Quest.XXX 等访问。");
|
||||
desc.style.whiteSpace = WhiteSpace.Normal;
|
||||
desc.style.marginBottom = 12;
|
||||
desc.style.paddingLeft = 8;
|
||||
desc.style.paddingRight = 8;
|
||||
desc.style.fontSize = 11;
|
||||
container.Add(desc);
|
||||
|
||||
var btn = new Button(RunCodegen) { text = "⚡ 生成 GameIds.Generated.cs" };
|
||||
btn.style.marginLeft = 8;
|
||||
btn.style.marginRight = 8;
|
||||
btn.style.height = 28;
|
||||
container.Add(btn);
|
||||
|
||||
_statusLabel = new Label(_lastResult ?? "");
|
||||
_statusLabel.style.whiteSpace = WhiteSpace.Normal;
|
||||
_statusLabel.style.marginTop = 10;
|
||||
_statusLabel.style.marginLeft = 8;
|
||||
_statusLabel.style.marginRight = 8;
|
||||
_statusLabel.style.fontSize = 11;
|
||||
container.Add(_statusLabel);
|
||||
}
|
||||
|
||||
public void BuildDetailPane(VisualElement container, UnityEngine.Object selected)
|
||||
{
|
||||
// 无需详情面板
|
||||
}
|
||||
|
||||
public void OnActivated() { }
|
||||
|
||||
// ── 代码生成 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void RunCodegen()
|
||||
{
|
||||
try
|
||||
{
|
||||
var quests = AssetOperations.FindAll<QuestSO>()
|
||||
.Where(q => !string.IsNullOrEmpty(q.questId))
|
||||
.Select(q => q.questId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(s => s, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var npcs = AssetOperations.FindAll<NpcSO>()
|
||||
.Where(n => !string.IsNullOrEmpty(n.npcId))
|
||||
.Select(n => n.npcId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(s => s, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var dialogues = AssetOperations.FindAll<DialogueSequenceSO>()
|
||||
.Where(d => !string.IsNullOrEmpty(d.sequenceId))
|
||||
.Select(d => d.sequenceId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(s => s, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var chains = AssetOperations.FindAll<EventChainSO>()
|
||||
.Where(c => !string.IsNullOrEmpty(c.chainId))
|
||||
.Select(c => c.chainId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(s => s, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
string code = BuildSourceCode(quests, npcs, dialogues, chains);
|
||||
|
||||
string fullPath = Path.GetFullPath(OutputPath);
|
||||
string dir = Path.GetDirectoryName(fullPath);
|
||||
if (!Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
File.WriteAllText(fullPath, code, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
AssetDatabase.ImportAsset(OutputPath, ImportAssetOptions.ForceUpdate);
|
||||
|
||||
int total = quests.Count + npcs.Count + dialogues.Count + chains.Count;
|
||||
_lastResult = $"✅ 生成完成({total} 个常量:Quest×{quests.Count} Npc×{npcs.Count} " +
|
||||
$"Dialogue×{dialogues.Count} Chain×{chains.Count})";
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_lastResult = $"❌ 生成失败:{e.Message}";
|
||||
Debug.LogException(e);
|
||||
}
|
||||
|
||||
if (_statusLabel != null)
|
||||
_statusLabel.text = _lastResult;
|
||||
}
|
||||
|
||||
private static string BuildSourceCode(List<string> quests, List<string> npcs,
|
||||
List<string> dialogues, List<string> chains)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("// <auto-generated>");
|
||||
sb.AppendLine("// 此文件由 DataHub > ID生成 模块自动生成,请勿手动编辑。");
|
||||
sb.AppendLine("// 手动维护的 ID 常量请放在 GameIds.cs 中。");
|
||||
sb.AppendLine("// </auto-generated>");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("namespace BaseGames.Core");
|
||||
sb.AppendLine("{");
|
||||
sb.AppendLine(" /// <summary>自动生成的游戏资产 ID 常量。每次执行 DataHub > ID生成 后刷新。</summary>");
|
||||
sb.AppendLine(" public static class GameIdsGenerated");
|
||||
sb.AppendLine(" {");
|
||||
|
||||
AppendSection(sb, "Quest", "QuestSO.questId", quests, "Quest_");
|
||||
AppendSection(sb, "Npc", "NpcSO.npcId", npcs, "NPC_");
|
||||
AppendSection(sb, "Dialogue", "DialogueSequenceSO.sequenceId", dialogues, "DLG_");
|
||||
AppendSection(sb, "Chain", "EventChainSO.chainId", chains, "Chain_");
|
||||
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine("}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void AppendSection(StringBuilder sb, string className, string docSource,
|
||||
List<string> ids, string stripPrefix)
|
||||
{
|
||||
sb.AppendLine($" /// <summary>来自 {docSource} 的 ID 常量。</summary>");
|
||||
sb.AppendLine($" public static class {className}");
|
||||
sb.AppendLine(" {");
|
||||
|
||||
if (ids.Count == 0)
|
||||
sb.AppendLine(" // 项目中暂无此类资产");
|
||||
else
|
||||
foreach (string id in ids)
|
||||
sb.AppendLine($" public const string {ToFieldName(id, stripPrefix)} = \"{id}\";");
|
||||
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将原始 ID 字符串转为合法的 C# 标识符。
|
||||
/// 剥离常见前缀(如 "Quest_"),然后将剩余部分中的非字母数字字符替换为下划线,
|
||||
/// 确保不以数字开头。
|
||||
/// </summary>
|
||||
private static string ToFieldName(string rawId, string stripPrefix)
|
||||
{
|
||||
string name = rawId;
|
||||
if (!string.IsNullOrEmpty(stripPrefix) &&
|
||||
name.StartsWith(stripPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
name = name.Substring(stripPrefix.Length);
|
||||
|
||||
// 将非字母数字字符替换为下划线
|
||||
name = Regex.Replace(name, @"[^A-Za-z0-9_]", "_");
|
||||
|
||||
// 不能以数字开头
|
||||
if (name.Length > 0 && char.IsDigit(name[0]))
|
||||
name = "_" + name;
|
||||
|
||||
if (string.IsNullOrEmpty(name))
|
||||
name = "_";
|
||||
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
335
Assets/_Game/Scripts/Editor/Modules/NpcModule.cs
Normal file
335
Assets/_Game/Scripts/Editor/Modules/NpcModule.cs
Normal file
@@ -0,0 +1,335 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Editor/Modules/NpcModule.cs.meta
Normal file
11
Assets/_Game/Scripts/Editor/Modules/NpcModule.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5d7d769256156a542bf7efb80f93f3c4
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -7,13 +7,14 @@ using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using BaseGames.Quest;
|
||||
using BaseGames.Editor.Shared;
|
||||
|
||||
namespace BaseGames.Editor.Modules
|
||||
{
|
||||
/// <summary>
|
||||
/// DataHub 任务模块 —— 管理 QuestSO 资产。
|
||||
/// </summary>
|
||||
public class QuestModule : IDataModule
|
||||
public class QuestModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string Folder = "Assets/_Game/Data/Quest";
|
||||
private const string Prefix = "Quest_";
|
||||
@@ -21,11 +22,15 @@ namespace BaseGames.Editor.Modules
|
||||
public string ModuleId => "quest";
|
||||
public string DisplayName => "任务";
|
||||
public string IconName => "d_UnityEditor.InspectorWindow";
|
||||
public int DisplayOrder => 110;
|
||||
|
||||
private SoListPane<QuestSO> _listPane;
|
||||
private DetailHeader _header;
|
||||
private QuestSO _selected;
|
||||
|
||||
// playModeStateChanged 订阅的字段引用,便于在重建 ActionBar 时退订旧订阅,避免内存泄漏
|
||||
private System.Action<UnityEditor.PlayModeStateChange> _playModeHandler;
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
_listPane = new SoListPane<QuestSO>(
|
||||
@@ -33,8 +38,20 @@ namespace BaseGames.Editor.Modules
|
||||
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<UnityEngine.Object> onSelected)
|
||||
@@ -44,10 +61,93 @@ namespace BaseGames.Editor.Modules
|
||||
_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<bool> 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<ClickEvent>(_ => SetActive(!active));
|
||||
return chip;
|
||||
}
|
||||
|
||||
public void BuildDetailPane(VisualElement container, UnityEngine.Object selected)
|
||||
{
|
||||
_selected = selected as QuestSO;
|
||||
@@ -63,6 +163,7 @@ namespace BaseGames.Editor.Modules
|
||||
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));
|
||||
@@ -86,11 +187,42 @@ namespace BaseGames.Editor.Modules
|
||||
int objCount = s.objectives != null ? s.objectives.Length : 0;
|
||||
|
||||
SkillModule.AddChip(card, "ID", string.IsNullOrEmpty(s.questId) ? "(未设置)" : s.questId);
|
||||
SkillModule.AddChip(card, "名称 Key", string.IsNullOrEmpty(s.displayNameKey) ? "(未设置)" : s.displayNameKey);
|
||||
|
||||
// 名称:优先显示本地化实际文本,回退到 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,方便策划一眼看清依赖链
|
||||
@@ -110,7 +242,24 @@ namespace BaseGames.Editor.Modules
|
||||
if (s.canFail)
|
||||
SkillModule.AddChip(card, "可失败", "✓");
|
||||
if (s.reward != null)
|
||||
SkillModule.AddChip(card, "奖励", s.reward.name);
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -185,6 +334,23 @@ namespace BaseGames.Editor.Modules
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -226,20 +392,508 @@ namespace BaseGames.Editor.Modules
|
||||
return card;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建当前任务的依赖关系可视图(折叠面板形式):
|
||||
/// - 上方:前置任务链(此任务需要哪些任务先完成)
|
||||
/// - 下方:后续任务链(此任务完成后可解锁哪些任务)
|
||||
/// 数据来源:allQuests 中所有 QuestSO 的 prerequisiteQuests 引用,无运行时副作用。
|
||||
/// 节点可点击→选中对应资产(EditorGUIUtility.PingObject)。
|
||||
/// </summary>
|
||||
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<QuestSO>();
|
||||
|
||||
// ── 前置任务(上游)────────────────────────────────────────────────
|
||||
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<string>(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 递归检测任务是否存在循环前置依赖(DFS)。
|
||||
/// visited 存储已访问的 questId,origin 为检测起点。
|
||||
/// </summary>
|
||||
private static bool HasPrerequisiteCycle(QuestSO origin, QuestSO current, System.Collections.Generic.HashSet<string> 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;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>添加一个依赖关系分区(标题 + 节点列表)。</summary>
|
||||
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));
|
||||
onDeleted: () => _listPane.Refresh(null),
|
||||
wizardCreate: cb => AssetCreationWizard.Show<QuestSO>(
|
||||
Folder, Prefix,
|
||||
(q, id) =>
|
||||
{
|
||||
q.questId = id;
|
||||
EditorUtility.SetDirty(q);
|
||||
AssetDatabase.SaveAssets();
|
||||
cb(q);
|
||||
}));
|
||||
|
||||
// 任务模块额外:代码常量生成
|
||||
new Button(GenerateQuestKeys) { text = "生成常量" }.AlsoAddTo(bar);
|
||||
// 任务模块额外:代码常量生成 + 批量配置验证
|
||||
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;
|
||||
}
|
||||
|
||||
// ── 运行时模拟 ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// PlayMode 下对当前选中的 QuestSO 模拟状态推进或重置:
|
||||
/// - Available → AcceptQuest
|
||||
/// - Active → CompleteQuest(传入 null rewardTarget,跳过奖励发放)
|
||||
/// - Completed / Failed / Unavailable → ResetQuest(重置为 Available 供重测)
|
||||
/// 用于策划/开发人员在不启动游戏流程的情况下快速验证任务状态机。
|
||||
/// </summary>
|
||||
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<IQuestManager>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 批量验证 ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 遍历所有 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 中展示,每项问题附"选中"按钮可一键定位资产。
|
||||
/// </summary>
|
||||
private static void ValidateAllQuests()
|
||||
{
|
||||
var allQuests = AssetOperations.FindAll<QuestSO>();
|
||||
var issues = new 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 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<string, QuestSO> ValidateIds(
|
||||
List<QuestSO> allQuests,
|
||||
System.Action<string, UnityEngine.Object> addError)
|
||||
{
|
||||
var idMap = new Dictionary<string, QuestSO>(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<QuestSO> allQuests,
|
||||
Dictionary<string, QuestSO> idMap,
|
||||
System.Action<string, UnityEngine.Object> addError,
|
||||
System.Action<string, UnityEngine.Object> 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<string>(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<string, UnityEngine.Object> addWarn)
|
||||
{
|
||||
var reachTagToSO = new Dictionary<string, BaseGames.Quest.ReachAreaObjective>(StringComparer.Ordinal);
|
||||
foreach (var obj in AssetOperations.FindAll<BaseGames.Quest.ReachAreaObjective>())
|
||||
if (!string.IsNullOrEmpty(obj.markerTag))
|
||||
reachTagToSO[obj.markerTag] = obj;
|
||||
|
||||
var triggerTagToPrefab = new Dictionary<string, GameObject>(StringComparer.Ordinal);
|
||||
foreach (var guid in AssetDatabase.FindAssets("t:Prefab"))
|
||||
{
|
||||
var prefabPath = AssetDatabase.GUIDToAssetPath(guid);
|
||||
var prefabGo = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
|
||||
if (prefabGo == null) continue;
|
||||
foreach (var zone in prefabGo.GetComponentsInChildren<BaseGames.World.TriggerZone>(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<QuestSO> allQuests,
|
||||
System.Action<string, UnityEngine.Object> addError)
|
||||
{
|
||||
foreach (var q in allQuests)
|
||||
{
|
||||
if (q.objectives == null || q.objectives.Length == 0) continue;
|
||||
var seenIds = new HashSet<string>(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<QuestSO> allQuests,
|
||||
System.Action<string, UnityEngine.Object> 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<QuestSO> allQuests,
|
||||
System.Action<string, UnityEngine.Object> addWarn)
|
||||
{
|
||||
var knownIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var guid in AssetDatabase.FindAssets("t:Prefab"))
|
||||
{
|
||||
var prefabPath = AssetDatabase.GUIDToAssetPath(guid);
|
||||
var go = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
|
||||
if (go == null) continue;
|
||||
var col = go.GetComponent<BaseGames.World.Collectible>();
|
||||
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<string, QuestSO> idMap,
|
||||
HashSet<string> 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";
|
||||
|
||||
11
Assets/_Game/Scripts/Editor/Modules/QuestModule.cs.meta
Normal file
11
Assets/_Game/Scripts/Editor/Modules/QuestModule.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cdb78fdcbe3a25f40b0f77d1b42002b9
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Editor.Modules
|
||||
{
|
||||
/// <summary>
|
||||
/// 批量验证结果窗口:以可滚动列表展示各项配置问题,
|
||||
/// 每条记录附带"选中"按钮,点击后高亮并选中对应资产(EditorGUIUtility.PingObject)。
|
||||
/// 支持 Error/Warning Tab 切换与文本搜索过滤。
|
||||
/// 由各验证模块(QuestModule、DialogueModule、NpcModule)在检测到问题时弹出。
|
||||
/// </summary>
|
||||
internal class QuestValidationResultWindow : EditorWindow
|
||||
{
|
||||
internal struct Issue
|
||||
{
|
||||
public string message;
|
||||
public bool isError;
|
||||
public UnityEngine.Object asset; // null = 无对应资产(如孤儿触发器)
|
||||
}
|
||||
|
||||
private List<Issue> _issues;
|
||||
private int _errorCount;
|
||||
private int _warnCount;
|
||||
private int _totalItems;
|
||||
private string _itemLabel = "资产";
|
||||
private Vector2 _scroll;
|
||||
|
||||
// ── 过滤状态 ─────────────────────────────────────────────────────────
|
||||
private enum TabMode { All, ErrorsOnly, WarnsOnly }
|
||||
private TabMode _tab = TabMode.All;
|
||||
private string _filter = "";
|
||||
|
||||
private static readonly GUIStyle s_tabActive = null; // 延迟初始化
|
||||
private static readonly GUIStyle s_tabInactive = null;
|
||||
|
||||
// ── 打开入口 ──────────────────────────────────────────────────────────
|
||||
|
||||
internal static void Show(List<Issue> issues, int errorCount, int warnCount, int totalItems, string windowTitle = "批量验证结果", string itemLabel = "资产")
|
||||
{
|
||||
var win = GetWindow<QuestValidationResultWindow>(true, windowTitle, true);
|
||||
win._issues = issues;
|
||||
win._errorCount = errorCount;
|
||||
win._warnCount = warnCount;
|
||||
win._totalItems = totalItems;
|
||||
win._itemLabel = itemLabel;
|
||||
win._tab = TabMode.All;
|
||||
win._filter = "";
|
||||
win.minSize = new Vector2(560, 380);
|
||||
win.Show();
|
||||
}
|
||||
|
||||
// ── 绘制 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
if (_issues == null) { EditorGUILayout.LabelField("无数据。"); return; }
|
||||
|
||||
bool clean = _errorCount == 0 && _warnCount == 0;
|
||||
|
||||
// ── 标题摘要 ──
|
||||
EditorGUILayout.Space(6);
|
||||
var summaryStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 12 };
|
||||
if (clean) summaryStyle.normal.textColor = new Color(0.2f, 0.75f, 0.2f);
|
||||
|
||||
string prefix = clean ? "✅ " : (_errorCount > 0 ? "❌ " : "⚠ ");
|
||||
string summary = $"{prefix}验证完成:{_totalItems} 个{_itemLabel} · {_errorCount} 个错误 · {_warnCount} 个警告";
|
||||
EditorGUILayout.LabelField(summary, summaryStyle);
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
if (clean)
|
||||
{
|
||||
EditorGUILayout.LabelField($"所有 {_itemLabel} 配置均合法!", EditorStyles.centeredGreyMiniLabel);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 分隔线 ──
|
||||
var divRect = EditorGUILayout.GetControlRect(false, 1);
|
||||
EditorGUI.DrawRect(divRect, new Color(0.35f, 0.35f, 0.35f));
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
// ── Tab 切换 + 搜索框 ──
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
DrawTab("全部", TabMode.All, _issues.Count);
|
||||
DrawTab("错误", TabMode.ErrorsOnly, _errorCount);
|
||||
DrawTab("警告", TabMode.WarnsOnly, _warnCount);
|
||||
GUILayout.FlexibleSpace();
|
||||
GUILayout.Label("🔍", GUILayout.Width(18));
|
||||
_filter = EditorGUILayout.TextField(_filter, GUILayout.Width(180));
|
||||
if (GUILayout.Button("×", GUILayout.Width(22))) _filter = "";
|
||||
EditorGUILayout.EndHorizontal();
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
// ── 问题列表 ──
|
||||
_scroll = EditorGUILayout.BeginScrollView(_scroll);
|
||||
int shown = 0;
|
||||
foreach (var issue in _issues)
|
||||
{
|
||||
if (!MatchesFilter(issue)) continue;
|
||||
shown++;
|
||||
DrawIssueRow(issue);
|
||||
EditorGUILayout.Space(2);
|
||||
}
|
||||
if (shown == 0)
|
||||
EditorGUILayout.LabelField("(当前过滤条件下无结果)", EditorStyles.centeredGreyMiniLabel);
|
||||
EditorGUILayout.EndScrollView();
|
||||
|
||||
// ── 底部状态栏 ──
|
||||
EditorGUILayout.Space(2);
|
||||
var statusRect = EditorGUILayout.GetControlRect(false, 1);
|
||||
EditorGUI.DrawRect(statusRect, new Color(0.3f, 0.3f, 0.3f));
|
||||
EditorGUILayout.LabelField(
|
||||
$"显示 {shown} / {_issues.Count} 条",
|
||||
EditorStyles.centeredGreyMiniLabel);
|
||||
}
|
||||
|
||||
// ── 辅助方法 ─────────────────────────────────────────────────────────
|
||||
|
||||
private bool MatchesFilter(Issue issue)
|
||||
{
|
||||
if (_tab == TabMode.ErrorsOnly && !issue.isError) return false;
|
||||
if (_tab == TabMode.WarnsOnly && issue.isError) return false;
|
||||
if (!string.IsNullOrEmpty(_filter))
|
||||
{
|
||||
bool msgMatch = issue.message.Contains(_filter, System.StringComparison.OrdinalIgnoreCase);
|
||||
bool assetMatch = issue.asset != null &&
|
||||
issue.asset.name.Contains(_filter, System.StringComparison.OrdinalIgnoreCase);
|
||||
if (!msgMatch && !assetMatch) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void DrawTab(string label, TabMode mode, int count)
|
||||
{
|
||||
bool active = _tab == mode;
|
||||
var style = active
|
||||
? new GUIStyle(EditorStyles.miniButtonMid) { fontStyle = FontStyle.Bold }
|
||||
: EditorStyles.miniButtonMid;
|
||||
if (active) style.normal.textColor = new Color(0.4f, 0.8f, 1f);
|
||||
string text = $"{label} ({count})";
|
||||
if (GUILayout.Button(text, style, GUILayout.MinWidth(72)))
|
||||
_tab = mode;
|
||||
}
|
||||
|
||||
private static void DrawIssueRow(Issue issue)
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal(EditorStyles.helpBox);
|
||||
|
||||
// 图标
|
||||
var iconContent = issue.isError
|
||||
? EditorGUIUtility.IconContent("console.erroricon.sml")
|
||||
: EditorGUIUtility.IconContent("console.warnicon.sml");
|
||||
GUILayout.Label(iconContent, GUILayout.Width(20), GUILayout.Height(18));
|
||||
|
||||
// 消息文字
|
||||
EditorGUILayout.LabelField(issue.message, EditorStyles.wordWrappedLabel);
|
||||
|
||||
// 定位按钮(有资产引用时显示)
|
||||
if (issue.asset != null)
|
||||
{
|
||||
if (GUILayout.Button("选中", GUILayout.Width(40), GUILayout.Height(18)))
|
||||
{
|
||||
EditorGUIUtility.PingObject(issue.asset);
|
||||
Selection.activeObject = issue.asset;
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cd040e3f82e3b3040a92fac14502ef4a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -10,7 +10,7 @@ namespace BaseGames.Editor.Modules
|
||||
/// <summary>
|
||||
/// DataHub 技能模块 —— 管理 FormSkillSO 资产。
|
||||
/// </summary>
|
||||
public class SkillModule : IDataModule
|
||||
public class SkillModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string Folder = "Assets/_Game/Data/Skills";
|
||||
private const string Prefix = "SKL_";
|
||||
@@ -18,6 +18,7 @@ namespace BaseGames.Editor.Modules
|
||||
public string ModuleId => "skill";
|
||||
public string DisplayName => "技能";
|
||||
public string IconName => null;
|
||||
public int DisplayOrder => 20;
|
||||
|
||||
private SoListPane<FormSkillSO> _listPane;
|
||||
private DetailHeader _header;
|
||||
@@ -193,16 +194,24 @@ namespace BaseGames.Editor.Modules
|
||||
T asset,
|
||||
string folder,
|
||||
string prefix,
|
||||
Action<T> onCreated,
|
||||
Action<T> onCloned,
|
||||
Action onDeleted) where T : UnityEngine.ScriptableObject
|
||||
Action<T> onCreated,
|
||||
Action<T> onCloned,
|
||||
Action onDeleted,
|
||||
Action<Action<T>> wizardCreate = null) where T : UnityEngine.ScriptableObject
|
||||
{
|
||||
var bar = MakeActionBar();
|
||||
|
||||
new Button(() =>
|
||||
{
|
||||
var c = AssetOperations.Create<T>(folder, prefix + "New");
|
||||
if (c != null) onCreated?.Invoke(c);
|
||||
if (wizardCreate != null)
|
||||
{
|
||||
wizardCreate(c => onCreated?.Invoke(c));
|
||||
}
|
||||
else
|
||||
{
|
||||
var c = AssetOperations.Create<T>(folder, prefix + "New");
|
||||
if (c != null) onCreated?.Invoke(c);
|
||||
}
|
||||
}) { text = "新建" }.AlsoAddTo(bar);
|
||||
|
||||
new Button(() =>
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace BaseGames.Editor.Modules
|
||||
/// <summary>
|
||||
/// DataHub 流式加载模块 —— 管理 <see cref="StreamingBudgetConfigSO"/> 资产。
|
||||
/// </summary>
|
||||
public class StreamingModule : IDataModule
|
||||
public class StreamingModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string Folder = "Assets/_Game/Data/Streaming";
|
||||
private const string Prefix = "STR_";
|
||||
@@ -18,6 +18,7 @@ namespace BaseGames.Editor.Modules
|
||||
public string ModuleId => "streaming";
|
||||
public string DisplayName => "流式加载";
|
||||
public string IconName => "d_RectTransformBlueprint";
|
||||
public int DisplayOrder => 70;
|
||||
|
||||
private SoListPane<StreamingBudgetConfigSO> _listPane;
|
||||
private DetailHeader _header;
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace BaseGames.Editor.Modules
|
||||
/// <summary>
|
||||
/// DataHub 武器模块 —— 管理 WeaponSO 资产。
|
||||
/// </summary>
|
||||
public class WeaponModule : IDataModule
|
||||
public class WeaponModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string Folder = "Assets/_Game/Data/Weapons";
|
||||
private const string Prefix = "WPN_";
|
||||
@@ -18,6 +18,7 @@ namespace BaseGames.Editor.Modules
|
||||
public string ModuleId => "weapon";
|
||||
public string DisplayName => "武器";
|
||||
public string IconName => null;
|
||||
public int DisplayOrder => 10;
|
||||
|
||||
private SoListPane<WeaponSO> _listPane;
|
||||
private DetailHeader _header;
|
||||
|
||||
Reference in New Issue
Block a user