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:
2026-05-25 00:05:15 +08:00
parent 446fd5dcd0
commit 6eaa83dc71
72 changed files with 7080 additions and 373 deletions

View File

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