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 { /// /// 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(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( 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, 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(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, "对话批量验证结果", "序列"); } } }