Files
zeling_v2/Assets/_Game/Scripts/Editor/Modules/DialogueModule.cs
Joywayer 6eaa83dc71 feat: Round 48 narrative systems improvements
- QuestSO: Add ValidateBranchCycles() DFS detection for branches[].nextQuest loop
- QuestSO: Mark three legacy prerequisite fields with v2.0 removal warning in Tooltip
- IQuestManager: Add QuestLockReason enum + QuestLockInfo struct (strongly-typed lock info)
- IQuestManager: Add GetQuestLockInfo() method to interface; GetQuestLockReason() now delegates to it
- IQuestEventSource: Add OnQuestStateChanged(questId, oldState, newState) unified event
- QuestManager: Implement GetQuestLockInfo(); fire OnQuestStateChanged on all state transitions
- DialogueManager: Add one-frame yield in HandleChoices before ShowChoices (skip-debounce fix)
- DialogueManager: Increment _playbackId in ForceEnd() to invalidate residual choice callbacks
- DialogueSequenceSO: Add UNITY_EDITOR debug log in TryGetActiveVariant on variant match
- WorldStateRegistry: Add OnBatchStateChanged event + BatchMark() batch-write API
- DialogueModule: List badge shows warning indicator for unconditional-shadowing variants
- DialogueModule: BuildVariantsCard shows logic mode (AND/OR) alongside flag conditions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-25 00:05:15 +08:00

561 lines
24 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
using BaseGames.Dialogue;
using BaseGames.Editor.Dialogue;
using BaseGames.Editor.Shared;
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(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<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, "对话批量验证结果", "序列");
}
}
}