using System;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
using BaseGames.Dialogue;
using BaseGames.Editor.Dialogue;
using BaseGames.Editor.Shared;
namespace BaseGames.Editor.Modules
{
///
/// DataHub 对话序列模块 —— 管理 DialogueSequenceSO 资产。
///
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 _listPane;
private DetailHeader _header;
private DialogueSequenceSO _selected;
public void Initialize()
{
_listPane = new SoListPane(
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 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(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();
}
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, "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, "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(
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;
}
// ── 批量验证 ─────────────────────────────────────────────────────────
///
/// 遍历所有 DialogueSequenceSO,检查:
/// 1. sequenceId 为空
/// 2. sequenceId 重复
/// 3. 每行 textKey 是否在本地化表中存在
/// 4. 每行 speakerNameKey(无 actor 时)是否在本地化表中存在
/// 5. 每个选项 textKey 是否在本地化表中存在
/// 结果显示在 QuestValidationResultWindow 中,每项问题附"选中"按钮可一键定位资产。
///
private static void ValidateAllSequences()
{
var allSeqs = AssetOperations.FindAll();
var issues = new System.Collections.Generic.List();
int errorCount = 0, warnCount = 0;
// 预构建本地化缓存(整个验证过程只查询一次,避免大批量序列时重复读取本地化表)
var locCache = new System.Collections.Generic.Dictionary(System.StringComparer.Ordinal);
string GetLoc(string key)
{
if (locCache.TryGetValue(key, out var v)) return v;
v = BaseGames.Localization.LocalizationManager.GetEditorPreview(key, "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(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, "对话批量验证结果", "序列");
}
}
}