Files
zeling_v2/Assets/_Game/Scripts/Editor/Modules/FlagAuditModule.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

509 lines
22 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 System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;
using BaseGames.Core;
using BaseGames.Dialogue;
using BaseGames.Quest;
using BaseGames.EventChain;
using BaseGames.Editor;
namespace BaseGames.Editor.Modules
{
/// <summary>
/// DataHub 标志审计模块 —— 扫描项目所有 WorldStateFlag 引用,
/// 检测孤立标志(已注册但从未使用)和未注册标志(已使用但未在注册表定义)。
/// </summary>
public class FlagAuditModule : IDataModule, IDataModuleOrdered
{
public string ModuleId => "flagaudit";
public string DisplayName => "标志审计";
public string IconName => "d_FilterByLabel";
public int DisplayOrder => 130;
// ── 数据 ─────────────────────────────────────────────────────────────
private readonly List<FlagRecord> _records = new();
private FlagRecord _selected;
private bool _hasScanned;
private class FlagRecord
{
public string id;
public string description;
public string group;
public bool isRegistered;
public readonly List<(string label, UnityEngine.Object asset)> setLocations = new();
public readonly List<(string label, UnityEngine.Object asset)> readLocations = new();
public bool IsOrphan => isRegistered && TotalUsages == 0;
public bool IsUnregistered => !isRegistered;
public int TotalUsages => setLocations.Count + readLocations.Count;
}
// ── UI 引用 ───────────────────────────────────────────────────────────
private VisualElement _listItems;
private Label _summaryLabel;
private VisualElement _detailRoot;
private bool _filterOrphan, _filterUnregistered;
// ── IDataModule ───────────────────────────────────────────────────────
public void Initialize() { }
public void BuildListPane(VisualElement container, Action<UnityEngine.Object> onSelected)
{
// 扫描按钮
var scanBtn = new Button(RunScan) { text = "🔍 扫描标志使用情况" };
scanBtn.style.marginTop = 8;
scanBtn.style.marginLeft = 8;
scanBtn.style.marginRight = 8;
scanBtn.style.marginBottom = 4;
container.Add(scanBtn);
// 统计行
_summaryLabel = new Label("尚未扫描,点击上方按钮开始。");
_summaryLabel.style.fontSize = 10;
_summaryLabel.style.opacity = 0.6f;
_summaryLabel.style.paddingLeft = 10;
_summaryLabel.style.marginBottom = 4;
container.Add(_summaryLabel);
// 过滤标签行
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);
filterRow.Add(QuestModule.MakeFilterChip("仅孤立", v => { _filterOrphan = v; RebuildList(); }));
filterRow.Add(QuestModule.MakeFilterChip("仅未注册", v => { _filterUnregistered = v; RebuildList(); }));
// 列表 ScrollView
var scroll = new ScrollView();
scroll.style.flexGrow = 1;
container.Add(scroll);
_listItems = new VisualElement();
scroll.Add(_listItems);
}
public void BuildDetailPane(VisualElement container, UnityEngine.Object selected)
{
_detailRoot = container;
RebuildDetail();
}
public void OnActivated() { }
// ── 扫描 ──────────────────────────────────────────────────────────────
private void RunScan()
{
_records.Clear();
_hasScanned = true;
var byId = new Dictionary<string, FlagRecord>(StringComparer.Ordinal);
FlagRecord GetOrCreate(string id)
{
if (!byId.TryGetValue(id, out var r))
{
r = new FlagRecord { id = id };
byId[id] = r;
_records.Add(r);
}
return r;
}
// 1. 从 WorldFlagRegistrySO 导入注册表
var registry = WorldFlagRegistrySO.EditorInstance;
if (registry?.flags != null)
foreach (var entry in registry.flags)
{
if (string.IsNullOrEmpty(entry.id)) continue;
var r = GetOrCreate(entry.id);
r.isRegistered = true;
r.description = entry.description;
r.group = entry.group;
}
// 2. 扫描 DialogueSequenceSO
foreach (var seq in AssetOperations.FindAll<DialogueSequenceSO>())
{
// variants[i].requiredFlags → 读取
if (seq.variants != null)
foreach (var v in seq.variants)
if (v.requiredFlags != null)
foreach (var fid in v.requiredFlags)
if (!string.IsNullOrEmpty(fid))
GetOrCreate(fid).readLocations.Add(($"对话变体条件 [{seq.name}]", seq));
// lines[i].choices[j].setWorldFlag → 设置
if (seq.lines != null)
foreach (var line in seq.lines)
if (line.choices != null)
foreach (var ch in line.choices)
if (!string.IsNullOrEmpty(ch.setWorldFlag))
GetOrCreate(ch.setWorldFlag).setLocations.Add(($"对话选项设置 [{seq.name}]", seq));
}
// 3. 扫描 QuestSO
foreach (var quest in AssetOperations.FindAll<QuestSO>())
{
// branches[i].conditionFlags → 读取
if (quest.branches != null)
foreach (var branch in quest.branches)
if (branch.conditionFlags != null)
foreach (var fid in branch.conditionFlags)
if (!string.IsNullOrEmpty(fid))
GetOrCreate(fid).readLocations.Add(($"任务分支条件 [{quest.name}]", quest));
// prerequisiteFlags → 读取
if (quest.prerequisiteFlags != null)
foreach (var fid in quest.prerequisiteFlags)
if (!string.IsNullOrEmpty(fid))
GetOrCreate(fid).readLocations.Add(($"任务前置标志 [{quest.name}]", quest));
}
// 4. 扫描 FlagSetConditionEventChain 条件)→ 读取
foreach (var cond in AssetOperations.FindAll<FlagSetCondition>())
if (!string.IsNullOrEmpty(cond.flagId))
GetOrCreate(cond.flagId).readLocations.Add(($"链条件 [{cond.name}]", cond));
// 5. 扫描 SetFlagActionEventChain 动作)→ 设置
foreach (var act in AssetOperations.FindAll<SetFlagAction>())
if (!string.IsNullOrEmpty(act.flagId))
GetOrCreate(act.flagId).setLocations.Add(($"链动作 [{act.name}]", act));
// 6. 扫描 NarrativeNPC 预制件中的 DialogueVersion 条件标志
// NarrativeNPC 是 MonoBehaviour使用 SerializedObject 读取序列化字段以避免反射。
var prefabGuids = AssetDatabase.FindAssets("t:Prefab", new[] { "Assets/_Game" });
foreach (var guid in prefabGuids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path);
if (prefab == null) continue;
foreach (var npc in prefab.GetComponentsInChildren<NarrativeNPC>(true))
{
var so = new SerializedObject(npc);
var vProp = so.FindProperty("_dialogueVersions");
if (vProp == null || !vProp.isArray) continue;
for (int i = 0; i < vProp.arraySize; i++)
{
var elem = vProp.GetArrayElementAtIndex(i);
var reqProp = elem.FindPropertyRelative("requiredFlags");
var blockProp = elem.FindPropertyRelative("blockedByFlags");
if (reqProp != null && reqProp.isArray)
for (int j = 0; j < reqProp.arraySize; j++)
{
string fid = reqProp.GetArrayElementAtIndex(j).stringValue;
if (!string.IsNullOrEmpty(fid))
GetOrCreate(fid).readLocations.Add(($"NPC版本条件 [{prefab.name}]", prefab));
}
if (blockProp != null && blockProp.isArray)
for (int j = 0; j < blockProp.arraySize; j++)
{
string fid = blockProp.GetArrayElementAtIndex(j).stringValue;
if (!string.IsNullOrEmpty(fid))
GetOrCreate(fid).readLocations.Add(($"NPC版本屏蔽 [{prefab.name}]", prefab));
}
}
}
}
// 排序:未注册 → 孤立 → 正常,再按 ID 字典序
_records.Sort((a, b) =>
{
int pa = a.IsUnregistered ? 0 : a.IsOrphan ? 1 : 2;
int pb = b.IsUnregistered ? 0 : b.IsOrphan ? 1 : 2;
int c = pa.CompareTo(pb);
return c != 0 ? c : string.Compare(a.id, b.id, StringComparison.Ordinal);
});
RebuildList();
RebuildDetail();
}
// ── 列表重建 ─────────────────────────────────────────────────────────
private void RebuildList()
{
if (_listItems == null) return;
_listItems.Clear();
if (!_hasScanned) return;
int total = _records.Count;
int orphanCount = _records.Count(r => r.IsOrphan);
int unregCount = _records.Count(r => r.IsUnregistered);
if (_summaryLabel != null)
_summaryLabel.text = $"共 {total} 个标志 · 孤立 {orphanCount} · 未注册 {unregCount}";
foreach (var rec in _records)
{
if (_filterOrphan && !rec.IsOrphan) continue;
if (_filterUnregistered && !rec.IsUnregistered) continue;
bool isSelected = rec == _selected;
var row = new VisualElement();
row.style.flexDirection = FlexDirection.Row;
row.style.alignItems = Align.Center;
row.style.paddingTop = 3;
row.style.paddingBottom = 3;
row.style.paddingLeft = 8;
row.style.paddingRight = 8;
row.style.backgroundColor = isSelected
? new StyleColor(new Color(0.25f, 0.5f, 1f, 0.2f))
: StyleKeyword.None;
// 状态图标 + 颜色
string icon = rec.IsUnregistered ? "⚠" : rec.IsOrphan ? "○" : "●";
Color iconColor = rec.IsUnregistered
? new Color(1f, 0.4f, 0.2f)
: rec.IsOrphan
? new Color(1f, 0.85f, 0.1f)
: new Color(0.4f, 0.85f, 0.4f);
var iconLbl = new Label(icon);
iconLbl.style.fontSize = 10;
iconLbl.style.color = new StyleColor(iconColor);
iconLbl.style.width = 14;
iconLbl.style.flexShrink = 0;
row.Add(iconLbl);
var idLbl = new Label(rec.id);
idLbl.style.fontSize = 11;
idLbl.style.flexGrow = 1;
row.Add(idLbl);
// 使用次数徽章
if (rec.TotalUsages > 0)
{
var badge = new Label(rec.TotalUsages.ToString());
badge.style.fontSize = 9;
badge.style.opacity = 0.6f;
badge.style.paddingLeft = 4;
badge.style.paddingRight = 4;
badge.style.paddingTop = 1;
badge.style.paddingBottom = 1;
badge.style.borderTopLeftRadius = 8;
badge.style.borderTopRightRadius = 8;
badge.style.borderBottomLeftRadius = 8;
badge.style.borderBottomRightRadius = 8;
badge.style.backgroundColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.25f));
row.Add(badge);
}
var capturedRec = rec;
row.RegisterCallback<ClickEvent>(_ =>
{
_selected = capturedRec;
RebuildList();
RebuildDetail();
});
_listItems.Add(row);
}
}
// ── 详情重建 ─────────────────────────────────────────────────────────
private void RebuildDetail()
{
if (_detailRoot == null) return;
_detailRoot.Clear();
if (!_hasScanned)
{
var hint = new Label("请先点击「扫描标志使用情况」按钮。");
hint.style.opacity = 0.5f;
hint.style.marginTop = 24;
hint.style.unityTextAlign = TextAnchor.UpperCenter;
_detailRoot.Add(hint);
return;
}
if (_selected == null)
{
var hint = new Label("← 从左侧选择一个标志查看详情。");
hint.style.opacity = 0.5f;
hint.style.marginTop = 24;
hint.style.unityTextAlign = TextAnchor.UpperCenter;
_detailRoot.Add(hint);
return;
}
var r = _selected;
// 标题
var titleLbl = new Label(r.id);
titleLbl.style.fontSize = 15;
titleLbl.style.unityFontStyleAndWeight = FontStyle.Bold;
titleLbl.style.paddingLeft = 12;
titleLbl.style.paddingTop = 12;
titleLbl.style.paddingBottom = 2;
_detailRoot.Add(titleLbl);
// 状态徽章
string statusText = r.IsUnregistered ? "⚠ 未在注册表中定义" : r.IsOrphan ? "○ 已注册但从未使用(孤立)" : "● 正常";
Color statusColor = r.IsUnregistered ? new Color(1f, 0.4f, 0.2f) : r.IsOrphan ? new Color(1f, 0.85f, 0.1f) : new Color(0.4f, 0.85f, 0.4f);
var statusLbl = new Label(statusText);
statusLbl.style.fontSize = 11;
statusLbl.style.color = new StyleColor(statusColor);
statusLbl.style.paddingLeft = 12;
statusLbl.style.marginBottom = 4;
_detailRoot.Add(statusLbl);
// "注册到注册表" 快捷按钮(仅未注册标志显示)
if (r.IsUnregistered)
{
var capturedRec = r;
var regBtn = new Button(() => RegisterFlagToRegistry(capturedRec))
{
text = " 注册到注册表",
tooltip = "将此标志 ID 追加到 WorldFlagRegistrySO.flags[] 中,并重新扫描。",
};
regBtn.style.marginLeft = 10;
regBtn.style.marginBottom = 6;
regBtn.style.width = 130;
_detailRoot.Add(regBtn);
}
if (!string.IsNullOrEmpty(r.group)) AddDetailRow("分组", r.group);
if (!string.IsNullOrEmpty(r.description)) AddDetailRow("描述", r.description);
_detailRoot.Add(SkillModule.MakeDivider());
AddLocationSection("📝 设置位置", r.setLocations, "无设置记录(标志只被读取,从不被写入)");
AddLocationSection("🔎 读取位置", r.readLocations, "无读取记录(标志只被写入,从不被读取)");
}
private void AddDetailRow(string label, string value)
{
var row = new VisualElement();
row.style.flexDirection = FlexDirection.Row;
row.style.paddingLeft = 12;
row.style.paddingBottom = 2;
var lbl = new Label($"{label}");
lbl.style.fontSize = 11;
lbl.style.opacity = 0.55f;
lbl.style.width = 48;
lbl.style.flexShrink = 0;
var val = new Label(value);
val.style.fontSize = 11;
val.style.flexGrow = 1;
val.style.flexWrap = Wrap.Wrap;
row.Add(lbl);
row.Add(val);
_detailRoot.Add(row);
}
private void AddLocationSection(string sectionTitle, List<(string label, UnityEngine.Object asset)> locations, string emptyText)
{
var header = new Label(sectionTitle);
header.style.fontSize = 11;
header.style.unityFontStyleAndWeight = FontStyle.Bold;
header.style.paddingLeft = 12;
header.style.paddingTop = 8;
header.style.paddingBottom = 3;
_detailRoot.Add(header);
if (locations.Count == 0)
{
var empty = new Label(emptyText);
empty.style.fontSize = 10;
empty.style.opacity = 0.45f;
empty.style.paddingLeft = 20;
empty.style.marginBottom = 4;
_detailRoot.Add(empty);
return;
}
foreach (var (lbl, asset) in locations)
{
var row = new VisualElement();
row.style.flexDirection = FlexDirection.Row;
row.style.alignItems = Align.Center;
row.style.paddingLeft = 14;
row.style.paddingRight = 8;
row.style.marginBottom = 2;
var caption = new Label(lbl);
caption.style.fontSize = 11;
caption.style.flexGrow = 1;
row.Add(caption);
if (asset != null)
{
var pingBtn = new Button(() =>
{
EditorGUIUtility.PingObject(asset);
Selection.activeObject = asset;
}) { text = "选中" };
pingBtn.style.fontSize = 10;
pingBtn.style.width = 36;
pingBtn.style.height = 18;
pingBtn.style.paddingTop = 0;
pingBtn.style.paddingBottom = 0;
row.Add(pingBtn);
}
_detailRoot.Add(row);
}
}
// ── 注册快捷操作 ──────────────────────────────────────────────────────
private void RegisterFlagToRegistry(FlagRecord rec)
{
var registry = WorldFlagRegistrySO.EditorInstance;
if (registry == null)
{
EditorUtility.DisplayDialog(
"注册表不存在",
"项目中未找到 WorldFlagRegistrySO 资产。\n" +
"请先通过 Create → BaseGames/Core/WorldFlagRegistry 创建注册表。",
"确定");
return;
}
// 检查是否已存在(理论上不可能,但防御性检查)
if (registry.flags != null)
{
foreach (var entry in registry.flags)
if (entry.id == rec.id) return;
}
var newEntry = new FlagEntry
{
id = rec.id,
description = "",
group = "",
};
var flags = registry.flags ?? System.Array.Empty<FlagEntry>();
var list = new System.Collections.Generic.List<FlagEntry>(flags) { newEntry };
Undo.RegisterCompleteObjectUndo(registry, $"注册标志 {rec.id}");
registry.flags = list.ToArray();
EditorUtility.SetDirty(registry);
AssetDatabase.SaveAssets();
// 将记录标记为已注册并重建 UI
rec.isRegistered = true;
RebuildList();
RebuildDetail();
Debug.Log($"[FlagAuditModule] 已将标志 '{rec.id}' 注册到 WorldFlagRegistrySO。");
}
}
}