562 lines
25 KiB
C#
562 lines
25 KiB
C#
using System;
|
||
using UnityEditor;
|
||
using UnityEditor.UIElements;
|
||
using UnityEngine;
|
||
using UnityEngine.UIElements;
|
||
using BaseGames.Dialogue;
|
||
using BaseGames.Editor.Dialogue;
|
||
using BaseGames.Editor.Shared;
|
||
using BaseGames.Localization;
|
||
|
||
namespace BaseGames.Editor.Modules
|
||
{
|
||
/// <summary>
|
||
/// DataHub 对话序列模块 —— 管理 DialogueSequenceSO 资产。
|
||
/// </summary>
|
||
public class DialogueModule : IDataModule, IDataModuleOrdered
|
||
{
|
||
private const string Folder = "Assets/_Game/Data/Dialogue";
|
||
private const string Prefix = "DLG_";
|
||
|
||
public string ModuleId => "dialogue";
|
||
public string DisplayName => "对话";
|
||
public string IconName => "d_UnityEditor.ConsoleWindow";
|
||
public int DisplayOrder => 100;
|
||
|
||
private SoListPane<DialogueSequenceSO> _listPane;
|
||
private DetailHeader _header;
|
||
private DialogueSequenceSO _selected;
|
||
|
||
public void Initialize()
|
||
{
|
||
_listPane = new SoListPane<DialogueSequenceSO>(
|
||
Folder, Prefix,
|
||
s =>
|
||
{
|
||
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)
|
||
{
|
||
_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 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(DataHubEditorKit.MakeFilterChip("有变体", v => { filterVariants = v; RebuildFilter(); }));
|
||
filterRow.Add(DataHubEditorKit.MakeFilterChip("有分支", v => { filterBranches = v; RebuildFilter(); }));
|
||
filterRow.Add(DataHubEditorKit.MakeFilterChip("无语音", v => { filterNoVoice = v; RebuildFilter(); }));
|
||
|
||
container.Add(_listPane);
|
||
_listPane.Refresh();
|
||
}
|
||
|
||
public void BuildDetailPane(VisualElement container, UnityEngine.Object selected)
|
||
{
|
||
_selected = selected as DialogueSequenceSO;
|
||
|
||
_header = new DetailHeader();
|
||
_header.SetAsset(_selected);
|
||
_header.RenameRequested += OnRenameRequested;
|
||
container.Add(_header);
|
||
|
||
if (_selected == null) return;
|
||
|
||
container.Add(BuildInfoCard(_selected));
|
||
container.Add(BuildLinesPreview(_selected));
|
||
if (_selected.variants != null && _selected.variants.Length > 0)
|
||
container.Add(BuildVariantsCard(_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(DialogueSequenceSO s)
|
||
{
|
||
var card = SkillModule.MakeCard();
|
||
int lineCount = s.lines != null ? s.lines.Length : 0;
|
||
int variantCount = s.variants != null ? s.variants.Length : 0;
|
||
|
||
SkillModule.AddChip(card, "ID", string.IsNullOrEmpty(s.sequenceId) ? "(未设置)" : s.sequenceId);
|
||
SkillModule.AddChip(card, "行数", lineCount.ToString());
|
||
if (variantCount > 0)
|
||
SkillModule.AddChip(card, "变体数", variantCount.ToString());
|
||
return card;
|
||
}
|
||
|
||
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;
|
||
section.style.paddingTop = 6;
|
||
section.style.paddingBottom = 6;
|
||
|
||
var title = new Label("对话预览(前 5 行)");
|
||
title.style.fontSize = 11;
|
||
title.style.opacity = 0.55f;
|
||
title.style.marginBottom = 4;
|
||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||
section.Add(title);
|
||
|
||
if (s.lines == null || s.lines.Length == 0)
|
||
{
|
||
var empty = new Label("(无对话行)");
|
||
empty.style.opacity = 0.4f;
|
||
empty.style.fontSize = 11;
|
||
section.Add(empty);
|
||
return section;
|
||
}
|
||
|
||
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 = 2;
|
||
|
||
// 头像图标(actor 优先,回退到直接字段)
|
||
var portrait = line.ResolvedPortrait;
|
||
if (portrait != null)
|
||
{
|
||
var img = new Image { image = portrait.texture };
|
||
img.style.width = 18;
|
||
img.style.height = 18;
|
||
img.style.marginRight = 4;
|
||
img.style.borderTopLeftRadius = 2;
|
||
img.style.borderTopRightRadius = 2;
|
||
img.style.borderBottomLeftRadius = 2;
|
||
img.style.borderBottomRightRadius = 2;
|
||
row.Add(img);
|
||
}
|
||
|
||
// 语音图标
|
||
if (line.voiceClip != null)
|
||
{
|
||
var ico = EditorGUIUtility.IconContent("d_AudioClip Icon");
|
||
if (ico?.image != null)
|
||
{
|
||
var img = new Image { image = ico.image };
|
||
img.style.width = 14;
|
||
img.style.height = 14;
|
||
img.style.marginRight = 4;
|
||
row.Add(img);
|
||
}
|
||
}
|
||
|
||
// 说话人(actor 优先,回退到直接字段;尝试解析本地化实际文本)
|
||
string speakerKey = line.ResolvedNameKey;
|
||
if (!string.IsNullOrEmpty(speakerKey))
|
||
{
|
||
var speakerResolved = BaseGames.Localization.LocalizationManager.GetEditorPreview(speakerKey, LocalizationTable.Dialogue);
|
||
bool speakerMissing = speakerResolved == null;
|
||
string speakerText = speakerMissing ? speakerKey : speakerResolved;
|
||
var spk = new Label(speakerText + ":");
|
||
spk.style.fontSize = 11;
|
||
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 有值但无本地化内容时橙色 ⚠ 警告)
|
||
string textPreview;
|
||
bool textL10nMissing = false;
|
||
if (string.IsNullOrEmpty(line.textKey))
|
||
{
|
||
textPreview = "(空)";
|
||
}
|
||
else
|
||
{
|
||
var resolved = BaseGames.Localization.LocalizationManager.GetEditorPreview(line.textKey, LocalizationTable.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 > previewCount)
|
||
{
|
||
var more = new Label($"… 还有 {s.lines.Length - previewCount} 行");
|
||
more.style.opacity = 0.4f;
|
||
more.style.fontSize = 10;
|
||
section.Add(more);
|
||
}
|
||
|
||
return section;
|
||
}
|
||
|
||
private static VisualElement BuildVariantsCard(DialogueSequenceSO s)
|
||
{
|
||
var card = SkillModule.MakeCard();
|
||
card.style.flexDirection = FlexDirection.Column;
|
||
|
||
var title = new Label("条件变体");
|
||
title.style.fontSize = 11;
|
||
title.style.opacity = 0.55f;
|
||
title.style.marginBottom = 4;
|
||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||
card.Add(title);
|
||
|
||
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;
|
||
|
||
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)
|
||
{
|
||
var bar = SkillModule.BuildStandardActionBar(
|
||
s, Folder, Prefix,
|
||
onCreated: c => _listPane.Refresh(c),
|
||
onCloned: c => _listPane.Refresh(c),
|
||
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, LocalizationTable.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, "对话批量验证结果", "序列");
|
||
}
|
||
}
|
||
} |