- 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>
527 lines
21 KiB
C#
527 lines
21 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Linq;
|
||
using System.Text;
|
||
using UnityEditor;
|
||
using UnityEngine;
|
||
using UnityEngine.UIElements;
|
||
using BaseGames.EventChain;
|
||
using BaseGames.Editor.Shared;
|
||
|
||
namespace BaseGames.Editor.Modules
|
||
{
|
||
/// <summary>
|
||
/// DataHub 事件链模块 —— 管理 EventChainSO 资产。
|
||
/// 支持浏览、创建、删除、预览条件/动作列表,以及批量验证。
|
||
/// </summary>
|
||
public class EventChainModule : IDataModule, IDataModuleOrdered
|
||
{
|
||
private const string Folder = "Assets/_Game/Data/EventChains";
|
||
private const string Prefix = "Chain_";
|
||
|
||
public string ModuleId => "eventchain";
|
||
public string DisplayName => "事件链";
|
||
public string IconName => "d_Animation.Play";
|
||
public int DisplayOrder => 120;
|
||
|
||
private SoListPane<EventChainSO> _listPane;
|
||
private DetailHeader _header;
|
||
private EventChainSO _selected;
|
||
|
||
// ── IDataModule ───────────────────────────────────────────────────────
|
||
|
||
public void Initialize()
|
||
{
|
||
_listPane = new SoListPane<EventChainSO>(
|
||
Folder, Prefix,
|
||
c => c.repeatable ? "可重复" : null);
|
||
// 扩展搜索:chainId
|
||
_listPane.GetExtraSearchText = c => c.chainId;
|
||
}
|
||
|
||
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 filterRepeatable = false, filterNoCondition = false, filterNoAction = false;
|
||
|
||
void RebuildFilter()
|
||
{
|
||
if (!filterRepeatable && !filterNoCondition && !filterNoAction)
|
||
{
|
||
_listPane.ExtraFilter = null;
|
||
return;
|
||
}
|
||
_listPane.ExtraFilter = c =>
|
||
{
|
||
if (filterRepeatable && !c.repeatable) return false;
|
||
if (filterNoCondition && c.conditions != null && c.conditions.Length > 0) return false;
|
||
if (filterNoAction && c.actions != null && c.actions.Length > 0) return false;
|
||
return true;
|
||
};
|
||
}
|
||
|
||
filterRow.Add(QuestModule.MakeFilterChip("可重复", v => { filterRepeatable = v; RebuildFilter(); }));
|
||
filterRow.Add(QuestModule.MakeFilterChip("无条件", v => { filterNoCondition = v; RebuildFilter(); }));
|
||
filterRow.Add(QuestModule.MakeFilterChip("无动作", v => { filterNoAction = v; RebuildFilter(); }));
|
||
|
||
container.Add(_listPane);
|
||
_listPane.Refresh();
|
||
}
|
||
|
||
public void BuildDetailPane(VisualElement container, UnityEngine.Object selected)
|
||
{
|
||
_selected = selected as EventChainSO;
|
||
|
||
_header = new DetailHeader();
|
||
_header.SetAsset(_selected);
|
||
_header.RenameRequested += OnRenameRequested;
|
||
container.Add(_header);
|
||
|
||
if (_selected == null) return;
|
||
|
||
container.Add(BuildInfoCard(_selected));
|
||
container.Add(BuildConditionsList(_selected));
|
||
container.Add(BuildActionsList(_selected));
|
||
container.Add(BuildActionBar(_selected));
|
||
container.Add(SkillModule.MakeDivider());
|
||
container.Add(new UnityEditor.UIElements.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(EventChainSO c)
|
||
{
|
||
var card = SkillModule.MakeCard();
|
||
|
||
SkillModule.AddChip(card, "Chain ID",
|
||
string.IsNullOrEmpty(c.chainId) ? "(未设置)" : c.chainId);
|
||
SkillModule.AddChip(card, "可重复", c.repeatable ? "是" : "否");
|
||
|
||
if (c.actionDelay > 0f)
|
||
SkillModule.AddChip(card, "动作间隔", $"{c.actionDelay:F2}s");
|
||
|
||
int condCount = c.conditions?.Length ?? 0;
|
||
int actCount = c.actions?.Length ?? 0;
|
||
SkillModule.AddChip(card, "条件数量", condCount.ToString());
|
||
SkillModule.AddChip(card, "动作数量", actCount.ToString());
|
||
|
||
return card;
|
||
}
|
||
|
||
private static VisualElement BuildConditionsList(EventChainSO c)
|
||
{
|
||
var section = new VisualElement();
|
||
section.style.paddingLeft = 12;
|
||
section.style.paddingRight = 12;
|
||
section.style.paddingTop = 6;
|
||
section.style.paddingBottom = 4;
|
||
|
||
var title = new Label("触发条件");
|
||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||
title.style.fontSize = 11;
|
||
title.style.opacity = 0.8f;
|
||
title.style.marginBottom = 4;
|
||
section.Add(title);
|
||
|
||
bool hasGroups = c.conditionGroups != null && c.conditionGroups.Length > 0;
|
||
bool hasLegacy = c.conditions != null && c.conditions.Length > 0;
|
||
|
||
if (!hasGroups && !hasLegacy)
|
||
{
|
||
var empty = new Label("(无条件,链将立即在首次 EvaluateAll 时触发)");
|
||
empty.style.opacity = 0.5f;
|
||
empty.style.fontSize = 11;
|
||
section.Add(empty);
|
||
return section;
|
||
}
|
||
|
||
if (hasGroups)
|
||
{
|
||
// 新版 conditionGroups 展示
|
||
for (int g = 0; g < c.conditionGroups.Length; g++)
|
||
{
|
||
var group = c.conditionGroups[g];
|
||
|
||
// 组标题
|
||
var groupHeader = new VisualElement();
|
||
groupHeader.style.flexDirection = FlexDirection.Row;
|
||
groupHeader.style.alignItems = Align.Center;
|
||
groupHeader.style.marginBottom = 2;
|
||
groupHeader.style.marginTop = g > 0 ? 4 : 0;
|
||
|
||
var gLabel = new Label($"条件组 {g + 1}");
|
||
gLabel.style.fontSize = 11;
|
||
gLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||
gLabel.style.flexGrow = 1;
|
||
groupHeader.Add(gLabel);
|
||
|
||
var logicBadge = new Label(group.logic == BaseGames.Core.WorldStateFlagLogic.Or ? "Or(任一满足)" : "And(全部满足)");
|
||
logicBadge.style.fontSize = 10;
|
||
logicBadge.style.opacity = 0.6f;
|
||
groupHeader.Add(logicBadge);
|
||
section.Add(groupHeader);
|
||
|
||
if (group.conditions == null || group.conditions.Length == 0)
|
||
{
|
||
var noCondLabel = new Label(" (空组 — 无条件即视为满足)");
|
||
noCondLabel.style.opacity = 0.45f;
|
||
noCondLabel.style.fontSize = 11;
|
||
section.Add(noCondLabel);
|
||
continue;
|
||
}
|
||
|
||
for (int i = 0; i < group.conditions.Length; i++)
|
||
section.Add(BuildConditionRow(i, group.conditions[i], indent: true));
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// 旧版 conditions[](隐式 And)
|
||
var legacyNote = new Label("(旧版条件列表,隐式 And 逻辑;建议迁移至 conditionGroups)");
|
||
legacyNote.style.opacity = 0.45f;
|
||
legacyNote.style.fontSize = 10;
|
||
legacyNote.style.marginBottom = 2;
|
||
section.Add(legacyNote);
|
||
for (int i = 0; i < c.conditions.Length; i++)
|
||
section.Add(BuildConditionRow(i, c.conditions[i], indent: false));
|
||
}
|
||
|
||
// ── 运行时求值按钮(仅 Play Mode)──────────────────────────────
|
||
if (Application.isPlaying)
|
||
{
|
||
section.Add(BuildRuntimeEvalPanel(c));
|
||
}
|
||
|
||
return section;
|
||
}
|
||
|
||
private static VisualElement BuildConditionRow(int index, ChainCondition cond, bool indent)
|
||
{
|
||
var row = new VisualElement();
|
||
row.style.flexDirection = FlexDirection.Row;
|
||
row.style.alignItems = Align.Center;
|
||
row.style.marginBottom = 2;
|
||
if (indent) row.style.paddingLeft = 12;
|
||
|
||
var idx = new Label($"{index + 1}.");
|
||
idx.style.width = 18;
|
||
idx.style.opacity = 0.5f;
|
||
idx.style.fontSize = 11;
|
||
row.Add(idx);
|
||
|
||
if (cond == null)
|
||
{
|
||
var warn = new Label("⚠ null(Inspector 中有空槽,请检查)");
|
||
warn.style.color = new StyleColor(new Color(0.9f, 0.5f, 0.2f));
|
||
warn.style.fontSize = 11;
|
||
row.Add(warn);
|
||
}
|
||
else
|
||
{
|
||
// 运行时:直接显示 IsMet() 结果
|
||
if (Application.isPlaying)
|
||
{
|
||
bool met = cond.IsMet();
|
||
var statusIcon = new Label(met ? "✓" : "✗");
|
||
statusIcon.style.fontSize = 11;
|
||
statusIcon.style.width = 14;
|
||
statusIcon.style.color = new StyleColor(met
|
||
? new Color(0.2f, 0.75f, 0.35f)
|
||
: new Color(0.85f, 0.35f, 0.30f));
|
||
row.Add(statusIcon);
|
||
}
|
||
|
||
var typeName = new Label(cond.GetType().Name);
|
||
typeName.style.flexGrow = 1;
|
||
typeName.style.fontSize = 11;
|
||
row.Add(typeName);
|
||
|
||
var ping = new Button(() => { EditorGUIUtility.PingObject(cond); Selection.activeObject = cond; })
|
||
{ text = "定位" };
|
||
ping.style.fontSize = 10;
|
||
ping.style.height = 18;
|
||
row.Add(ping);
|
||
}
|
||
|
||
return row;
|
||
}
|
||
|
||
private static VisualElement BuildRuntimeEvalPanel(EventChainSO c)
|
||
{
|
||
var panel = new VisualElement();
|
||
panel.style.marginTop = 6;
|
||
panel.style.paddingTop = 4;
|
||
panel.style.paddingBottom = 4;
|
||
panel.style.paddingLeft = 8;
|
||
panel.style.paddingRight = 8;
|
||
panel.style.backgroundColor = new StyleColor(new Color(0.15f, 0.22f, 0.15f, 1f));
|
||
|
||
var header = new VisualElement();
|
||
header.style.flexDirection = FlexDirection.Row;
|
||
header.style.alignItems = Align.Center;
|
||
header.style.marginBottom = 2;
|
||
|
||
var title = new Label("▶ 运行时状态");
|
||
title.style.fontSize = 10;
|
||
title.style.opacity = 0.7f;
|
||
title.style.flexGrow = 1;
|
||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||
header.Add(title);
|
||
|
||
var resultLabel = new Label();
|
||
resultLabel.style.fontSize = 10;
|
||
|
||
void RefreshEval()
|
||
{
|
||
// 简单逐条 IsMet 求值用于显示(不触发实际执行)
|
||
bool hasGroups = c.conditionGroups != null && c.conditionGroups.Length > 0;
|
||
bool allPass = true;
|
||
if (hasGroups)
|
||
{
|
||
foreach (var g in c.conditionGroups)
|
||
{
|
||
if (g.conditions == null || g.conditions.Length == 0) continue;
|
||
bool groupPass = g.logic == BaseGames.Core.WorldStateFlagLogic.Or
|
||
? System.Array.Exists(g.conditions, x => x != null && x.IsMet())
|
||
: System.Array.TrueForAll(g.conditions, x => x == null || x.IsMet());
|
||
if (!groupPass) { allPass = false; break; }
|
||
}
|
||
}
|
||
else if (c.conditions != null)
|
||
{
|
||
allPass = System.Array.TrueForAll(c.conditions, x => x == null || x.IsMet());
|
||
}
|
||
|
||
resultLabel.text = allPass ? "✓ 当前满足触发条件" : "✗ 当前不满足触发条件";
|
||
resultLabel.style.color = new StyleColor(allPass
|
||
? new Color(0.2f, 0.75f, 0.35f)
|
||
: new Color(0.85f, 0.35f, 0.30f));
|
||
}
|
||
|
||
RefreshEval();
|
||
|
||
var refreshBtn = new Button(RefreshEval) { text = "刷新" };
|
||
refreshBtn.style.fontSize = 9;
|
||
refreshBtn.style.height = 16;
|
||
header.Add(refreshBtn);
|
||
|
||
panel.Add(header);
|
||
panel.Add(resultLabel);
|
||
return panel;
|
||
}
|
||
|
||
private static VisualElement BuildActionsList(EventChainSO c)
|
||
{
|
||
var section = new VisualElement();
|
||
section.style.paddingLeft = 12;
|
||
section.style.paddingRight = 12;
|
||
section.style.paddingTop = 6;
|
||
section.style.paddingBottom = 4;
|
||
|
||
var title = new Label("执行动作");
|
||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||
title.style.fontSize = 11;
|
||
title.style.opacity = 0.8f;
|
||
title.style.marginBottom = 4;
|
||
section.Add(title);
|
||
|
||
if (c.actions == null || c.actions.Length == 0)
|
||
{
|
||
var empty = new Label("(无动作,链触发后不执行任何操作)");
|
||
empty.style.opacity = 0.5f;
|
||
empty.style.fontSize = 11;
|
||
section.Add(empty);
|
||
return section;
|
||
}
|
||
|
||
for (int i = 0; i < c.actions.Length; i++)
|
||
{
|
||
var act = c.actions[i];
|
||
var row = new VisualElement();
|
||
row.style.flexDirection = FlexDirection.Row;
|
||
row.style.alignItems = Align.Center;
|
||
row.style.marginBottom = 2;
|
||
|
||
var idx = new Label($"{i + 1}.");
|
||
idx.style.width = 18;
|
||
idx.style.opacity = 0.5f;
|
||
idx.style.fontSize = 11;
|
||
row.Add(idx);
|
||
|
||
if (act == null)
|
||
{
|
||
var warn = new Label("⚠ null(Inspector 中有空槽,请检查)");
|
||
warn.style.color = new StyleColor(new Color(0.9f, 0.5f, 0.2f));
|
||
warn.style.fontSize = 11;
|
||
row.Add(warn);
|
||
}
|
||
else
|
||
{
|
||
var typeName = new Label($"{act.GetType().Name} {act.name}");
|
||
typeName.style.flexGrow = 1;
|
||
typeName.style.fontSize = 11;
|
||
row.Add(typeName);
|
||
|
||
var ping = new Button(() => { EditorGUIUtility.PingObject(act); Selection.activeObject = act; })
|
||
{ text = "定位" };
|
||
ping.style.fontSize = 10;
|
||
ping.style.height = 18;
|
||
row.Add(ping);
|
||
}
|
||
|
||
section.Add(row);
|
||
}
|
||
|
||
return section;
|
||
}
|
||
|
||
private VisualElement BuildActionBar(EventChainSO c)
|
||
{
|
||
var bar = SkillModule.MakeActionBar();
|
||
|
||
new Button(() =>
|
||
{
|
||
AssetCreationWizard.Show<EventChainSO>(Folder, Prefix, (asset, id) =>
|
||
{
|
||
asset.chainId = id;
|
||
EditorUtility.SetDirty(asset);
|
||
AssetDatabase.SaveAssets();
|
||
_listPane.Refresh(asset);
|
||
});
|
||
}) { text = "创建" }.AlsoAddTo(bar);
|
||
|
||
new Button(() => { EditorGUIUtility.PingObject(c); Selection.activeObject = c; })
|
||
{ text = "定位" }.AlsoAddTo(bar);
|
||
|
||
new Button(() =>
|
||
{
|
||
string path = AssetDatabase.GetAssetPath(c);
|
||
if (!string.IsNullOrEmpty(path)) EditorGUIUtility.systemCopyBuffer = path;
|
||
}) { text = "复制路径" }.AlsoAddTo(bar);
|
||
|
||
new Button(ValidateAllChains) { text = "批量验证" }.AlsoAddTo(bar);
|
||
|
||
// 强制触发按钮仅在 Play Mode 下显示,用于调试绕过条件检查
|
||
if (Application.isPlaying)
|
||
{
|
||
var capturedC = c;
|
||
var forceBtn = new Button(() =>
|
||
{
|
||
var mgr = UnityEngine.Object.FindObjectOfType<EventChainManager>();
|
||
if (mgr == null)
|
||
{
|
||
Debug.LogWarning("[EventChainModule] 场景中未找到 EventChainManager,无法强制触发。");
|
||
return;
|
||
}
|
||
Debug.Log($"[EventChainModule] 强制触发链:{capturedC.chainId}");
|
||
mgr.ForceExecute(capturedC.chainId);
|
||
}) { text = "⚡ 强制触发" };
|
||
forceBtn.style.color = new StyleColor(new Color(1f, 0.75f, 0.2f));
|
||
forceBtn.AlsoAddTo(bar);
|
||
}
|
||
|
||
var del = new Button(() =>
|
||
{
|
||
if (AssetOperations.Delete(c)) _listPane.Refresh(null);
|
||
}) { text = "删除" };
|
||
SkillModule.ApplyDeleteStyle(del);
|
||
del.AlsoAddTo(bar);
|
||
|
||
return bar;
|
||
}
|
||
|
||
// ── 批量验证 ──────────────────────────────────────────────────────────
|
||
|
||
private static void ValidateAllChains()
|
||
{
|
||
var allChains = AssetOperations.FindAll<EventChainSO>();
|
||
if (allChains.Count == 0)
|
||
{
|
||
EditorUtility.DisplayDialog("事件链验证", "项目中未找到任何 EventChainSO。", "确定");
|
||
return;
|
||
}
|
||
|
||
var issues = new List<QuestValidationResultWindow.Issue>();
|
||
int errorCount = 0, warnCount = 0;
|
||
|
||
void AddError(string msg, UnityEngine.Object asset)
|
||
{
|
||
issues.Add(new QuestValidationResultWindow.Issue { message = msg, isError = true, asset = asset });
|
||
errorCount++;
|
||
}
|
||
void AddWarn(string msg, UnityEngine.Object asset)
|
||
{
|
||
issues.Add(new QuestValidationResultWindow.Issue { message = msg, isError = false, asset = asset });
|
||
warnCount++;
|
||
}
|
||
|
||
// ① 空 chainId
|
||
foreach (var c in allChains)
|
||
if (string.IsNullOrWhiteSpace(c.chainId))
|
||
AddError($"{c.name}: chainId 为空,运行时无法存档或被 ChainCompletedCondition 引用。", c);
|
||
|
||
// ② 重复 chainId
|
||
var idMap = new Dictionary<string, EventChainSO>(StringComparer.Ordinal);
|
||
foreach (var c in allChains)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(c.chainId)) continue;
|
||
if (!idMap.TryAdd(c.chainId, c))
|
||
AddError($"chainId '{c.chainId}' 重复({idMap[c.chainId].name} 与 {c.name}),运行时存档键将互串。", c);
|
||
}
|
||
|
||
// ③ 无动作
|
||
foreach (var c in allChains)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(c.chainId)) continue;
|
||
if (c.actions == null || c.actions.Length == 0)
|
||
AddWarn($"{c.chainId}: actions 为空,链触发后不执行任何操作。", c);
|
||
}
|
||
|
||
// ④ conditions 含空槽
|
||
foreach (var c in allChains)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(c.chainId) || c.conditions == null) continue;
|
||
for (int i = 0; i < c.conditions.Length; i++)
|
||
if (c.conditions[i] == null)
|
||
AddError($"{c.chainId}: conditions[{i}] 为 null,运行时将触发 NullReferenceException。", c);
|
||
}
|
||
|
||
// ⑤ actions 含空槽
|
||
foreach (var c in allChains)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(c.chainId) || c.actions == null) continue;
|
||
for (int i = 0; i < c.actions.Length; i++)
|
||
if (c.actions[i] == null)
|
||
AddError($"{c.chainId}: actions[{i}] 为 null,运行时将触发 NullReferenceException。", c);
|
||
}
|
||
|
||
Debug.Log($"[EventChainModule] 验证完成:{allChains.Count} 条事件链,{errorCount} 个错误,{warnCount} 个警告。");
|
||
QuestValidationResultWindow.Show(issues, errorCount, warnCount, allChains.Count, "事件链批量验证结果", "事件链");
|
||
}
|
||
}
|
||
}
|