File directory changes (mirror Scripts/ module structure): - AbilityTypeDrawer.cs → Equipment/ - CharacterWizardWindow.cs → Character/ - FormEditorWindow.cs → Player/ - GMToolWindow.cs → Tools/ - SOManagerWindow.cs → Tools/ - Map/MapRoomDataEditor.cs → World/Map/ - Navigation/ (root) → Enemies/Navigation/ - Achievements/ → Progression/ Menu hierarchy changes (BaseGames/ top-level): - Data/: +Character Wizard (from Tools/), +Boss Skill Sequence (from Tools/) - Addressables/: +Addressable Batch Tool, +Asset Reference Graph, +Validate Address Keys (from Tools/Verification/) - Scene/Setup/: +Boot Flow Wizard, +Scaffold *, +Auto-Open Persistent (from Tools/) - Scene/: +Camera Area Setup (from Camera/), +Bake All NavSurfaces (from Tools/) - Events/: +Event Bus Monitor, +Event Chain Viewer, +Create/Reimport Event Channels (from Tools/) - Tools/Validation/: +Validate All SOs, +Apply/Validate Script Order (from Tools/ flat) - Tools/Maintenance/: +Missing Scripts/*, +Physics2D Layer Matrix/* (from Tools/ flat) Result: BaseGames/Tools/ reduced from 16 flat items to 4 items + 2 submenus Docs: update AssetFolderSpec §12 editor tool table with new menu paths Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
304 lines
13 KiB
C#
304 lines
13 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Text.RegularExpressions;
|
||
using UnityEditor;
|
||
using UnityEditor.AddressableAssets;
|
||
using UnityEngine;
|
||
|
||
namespace BaseGames.Editor
|
||
{
|
||
/// <summary>
|
||
/// Addressable Key 引用关系图窗口(架构 13_AssetPoolModule §11)。
|
||
/// 菜单:BaseGames/Tools/Asset Reference Graph
|
||
///
|
||
/// 功能:
|
||
/// - 扫描所有 .cs 文件中对 AddressKeys.X 的引用
|
||
/// - 列出每个 Key:声明位置、引用文件列表、是否存在于 Addressables
|
||
/// - 孤儿 Key(有声明无引用)标红显示
|
||
/// - 无效 Key(有引用但不存在于 Addressables)标橙显示
|
||
/// - 一键导出 CSV
|
||
/// </summary>
|
||
public class AddressReferenceGraphWindow : EditorWindow
|
||
{
|
||
// ── State ──────────────────────────────────────────────────────────
|
||
private List<KeyEntry> _entries;
|
||
private Vector2 _scrollPos;
|
||
private string _searchFilter = "";
|
||
private bool _showOrphansOnly;
|
||
private bool _showMissingOnly;
|
||
|
||
// ── Colors ─────────────────────────────────────────────────────────
|
||
private static readonly Color ColOrphan = new Color(0.90f, 0.15f, 0.15f, 0.80f); // 孤儿 Key(无引用)
|
||
private static readonly Color ColMissing = new Color(0.95f, 0.55f, 0.10f, 0.80f); // 无效 Key(不在 Addressables)
|
||
private static readonly Color ColOk = new Color(0.20f, 0.75f, 0.30f, 0.80f); // 正常
|
||
|
||
[MenuItem("BaseGames/Addressables/Asset Reference Graph")]
|
||
public static void OpenWindow()
|
||
{
|
||
var win = GetWindow<AddressReferenceGraphWindow>("Asset Reference Graph");
|
||
win.minSize = new Vector2(900, 500);
|
||
win.Show();
|
||
}
|
||
|
||
// ── GUI ────────────────────────────────────────────────────────────
|
||
|
||
private void OnGUI()
|
||
{
|
||
DrawToolbar();
|
||
|
||
if (_entries == null)
|
||
{
|
||
EditorGUILayout.HelpBox("点击上方「扫描」按钮分析 AddressKeys 引用关系。", MessageType.Info);
|
||
return;
|
||
}
|
||
|
||
DrawFilterRow();
|
||
DrawResults();
|
||
}
|
||
|
||
// ── Toolbar ───────────────────────────────────────────────────────
|
||
|
||
private void DrawToolbar()
|
||
{
|
||
EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
|
||
|
||
if (GUILayout.Button("扫描", EditorStyles.toolbarButton, GUILayout.Width(60)))
|
||
RunScan();
|
||
|
||
if (_entries != null && GUILayout.Button("导出 CSV", EditorStyles.toolbarButton, GUILayout.Width(70)))
|
||
ExportCsv();
|
||
|
||
GUILayout.FlexibleSpace();
|
||
|
||
if (_entries != null)
|
||
{
|
||
int orphans = _entries.Count(e => e.ReferenceCount == 0);
|
||
int missing = _entries.Count(e => !e.ExistsInAddressables);
|
||
EditorGUILayout.LabelField(
|
||
$"共 {_entries.Count} 个 Key | 孤儿:{orphans} | 未在 Addressables:{missing}",
|
||
EditorStyles.toolbarButton);
|
||
}
|
||
|
||
EditorGUILayout.EndHorizontal();
|
||
}
|
||
|
||
// ── 过滤行 ────────────────────────────────────────────────────────
|
||
|
||
private void DrawFilterRow()
|
||
{
|
||
EditorGUILayout.BeginHorizontal();
|
||
EditorGUILayout.LabelField("搜索:", GUILayout.Width(40));
|
||
_searchFilter = EditorGUILayout.TextField(_searchFilter, GUILayout.ExpandWidth(true));
|
||
_showOrphansOnly = EditorGUILayout.ToggleLeft("仅显示孤儿", _showOrphansOnly, GUILayout.Width(90));
|
||
_showMissingOnly = EditorGUILayout.ToggleLeft("仅显示缺失", _showMissingOnly, GUILayout.Width(90));
|
||
EditorGUILayout.EndHorizontal();
|
||
}
|
||
|
||
// ── 结果列表 ──────────────────────────────────────────────────────
|
||
|
||
private void DrawResults()
|
||
{
|
||
var filtered = _entries.AsEnumerable();
|
||
|
||
if (_showOrphansOnly)
|
||
filtered = filtered.Where(e => e.ReferenceCount == 0);
|
||
if (_showMissingOnly)
|
||
filtered = filtered.Where(e => !e.ExistsInAddressables);
|
||
if (!string.IsNullOrEmpty(_searchFilter))
|
||
filtered = filtered.Where(e =>
|
||
e.FieldName.IndexOf(_searchFilter, System.StringComparison.OrdinalIgnoreCase) >= 0);
|
||
|
||
var list = filtered.ToList();
|
||
|
||
// 表头
|
||
EditorGUILayout.BeginHorizontal(EditorStyles.helpBox);
|
||
EditorGUILayout.LabelField("状态", GUILayout.Width(50));
|
||
EditorGUILayout.LabelField("Key 名称", GUILayout.Width(280));
|
||
EditorGUILayout.LabelField("地址值", GUILayout.Width(300));
|
||
EditorGUILayout.LabelField("引用数", GUILayout.Width(60));
|
||
EditorGUILayout.EndHorizontal();
|
||
|
||
_scrollPos = EditorGUILayout.BeginScrollView(_scrollPos);
|
||
|
||
foreach (var entry in list)
|
||
{
|
||
bool isOrphan = entry.ReferenceCount == 0;
|
||
bool isMissing = !entry.ExistsInAddressables;
|
||
|
||
Color statusColor = isOrphan ? ColOrphan : (isMissing ? ColMissing : ColOk);
|
||
string statusIcon = isOrphan ? "⊘" : (isMissing ? "⚠" : "✓");
|
||
|
||
var prevBg = GUI.backgroundColor;
|
||
GUI.backgroundColor = statusColor * 0.6f;
|
||
EditorGUILayout.BeginHorizontal("box");
|
||
GUI.backgroundColor = prevBg;
|
||
|
||
EditorGUILayout.LabelField(statusIcon, GUILayout.Width(50));
|
||
EditorGUILayout.LabelField(entry.FieldName, GUILayout.Width(280));
|
||
|
||
// 地址值可点击 → Ping Addressable asset
|
||
if (GUILayout.Button(entry.Value,
|
||
isOrphan ? EditorStyles.label : EditorStyles.miniButtonMid,
|
||
GUILayout.Width(300)))
|
||
{
|
||
PingAddressableAsset(entry.Value);
|
||
}
|
||
|
||
EditorGUILayout.LabelField(
|
||
$"{entry.ReferenceCount}",
|
||
GUILayout.Width(60));
|
||
|
||
EditorGUILayout.EndHorizontal();
|
||
|
||
// 展开:显示引用文件列表
|
||
if (entry.ReferenceCount > 0 && entry.ReferencedInFiles != null)
|
||
{
|
||
foreach (var file in entry.ReferencedInFiles)
|
||
{
|
||
EditorGUILayout.BeginHorizontal();
|
||
GUILayout.Space(60);
|
||
EditorGUILayout.LabelField($" ↳ {file}", EditorStyles.miniLabel);
|
||
EditorGUILayout.EndHorizontal();
|
||
}
|
||
}
|
||
}
|
||
|
||
EditorGUILayout.EndScrollView();
|
||
}
|
||
|
||
// ── 扫描逻辑 ──────────────────────────────────────────────────────
|
||
|
||
private void RunScan()
|
||
{
|
||
_entries = new List<KeyEntry>();
|
||
|
||
// 1. 收集所有 AddressKeys 常量
|
||
var keyFields = typeof(BaseGames.Core.Assets.AddressKeys)
|
||
.GetFields(System.Reflection.BindingFlags.Public
|
||
| System.Reflection.BindingFlags.Static
|
||
| System.Reflection.BindingFlags.FlattenHierarchy)
|
||
.Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string));
|
||
|
||
var keyDict = new Dictionary<string, KeyEntry>();
|
||
foreach (var f in keyFields)
|
||
{
|
||
var value = (string)f.GetRawConstantValue();
|
||
keyDict[f.Name] = new KeyEntry
|
||
{
|
||
FieldName = f.Name,
|
||
Value = value,
|
||
ExistsInAddressables = false,
|
||
ReferencedInFiles = new List<string>()
|
||
};
|
||
}
|
||
|
||
// 2. 检查 Addressables(直接内联,不依赖 Verification 程序集)
|
||
var registeredAddressValues = GetRegisteredAddressableAddresses();
|
||
|
||
foreach (var kv in keyDict)
|
||
kv.Value.ExistsInAddressables = registeredAddressValues.Contains(kv.Value.Value);
|
||
|
||
// 3. 扫描 .cs 文件引用
|
||
var csFiles = Directory.GetFiles(
|
||
Path.Combine(Application.dataPath, "Scripts"),
|
||
"*.cs",
|
||
SearchOption.AllDirectories);
|
||
|
||
foreach (var file in csFiles)
|
||
{
|
||
string content;
|
||
try { content = File.ReadAllText(file); }
|
||
catch { continue; }
|
||
|
||
foreach (var kv in keyDict)
|
||
{
|
||
// 匹配 AddressKeys.FieldName(单词边界,避免前缀误匹配)
|
||
if (Regex.IsMatch(content, $@"\bAddressKeys\.{Regex.Escape(kv.Key)}\b"))
|
||
{
|
||
string relativePath = "Assets" + file.Substring(Application.dataPath.Length).Replace('\\', '/');
|
||
kv.Value.ReferencedInFiles.Add(relativePath);
|
||
}
|
||
}
|
||
}
|
||
|
||
foreach (var kv in keyDict)
|
||
_entries.Add(kv.Value);
|
||
|
||
_entries.Sort((a, b) =>
|
||
{
|
||
// 孤儿排最前,其次缺失,最后正常
|
||
int aScore = a.ReferenceCount == 0 ? 0 : (!a.ExistsInAddressables ? 1 : 2);
|
||
int bScore = b.ReferenceCount == 0 ? 0 : (!b.ExistsInAddressables ? 1 : 2);
|
||
return aScore != bScore ? aScore.CompareTo(bScore) : string.Compare(a.FieldName, b.FieldName);
|
||
});
|
||
|
||
Debug.Log($"[AddressReferenceGraph] 扫描完成:{_entries.Count} 个 Key," +
|
||
$"{_entries.Count(e => e.ReferenceCount == 0)} 孤儿," +
|
||
$"{_entries.Count(e => !e.ExistsInAddressables)} 未在 Addressables。");
|
||
}
|
||
|
||
// ── CSV 导出 ──────────────────────────────────────────────────────
|
||
|
||
private void ExportCsv()
|
||
{
|
||
string path = EditorUtility.SaveFilePanel("导出 CSV", "", "AddressKeyReport", "csv");
|
||
if (string.IsNullOrEmpty(path)) return;
|
||
|
||
using var writer = new StreamWriter(path, false, System.Text.Encoding.UTF8);
|
||
writer.WriteLine("FieldName,Value,ExistsInAddressables,ReferenceCount,ReferencedFiles");
|
||
foreach (var e in _entries)
|
||
{
|
||
string files = e.ReferencedInFiles != null
|
||
? string.Join(" | ", e.ReferencedInFiles)
|
||
: "";
|
||
writer.WriteLine($"{e.FieldName},{e.Value},{e.ExistsInAddressables},{e.ReferenceCount},{files}");
|
||
}
|
||
|
||
Debug.Log($"[AddressReferenceGraph] CSV 已导出:{path}");
|
||
}
|
||
|
||
// ── Addressables 辅助(独立实现,不依赖 Verification 程序集)─────────
|
||
|
||
private static HashSet<string> GetRegisteredAddressableAddresses()
|
||
{
|
||
var addresses = new HashSet<string>(StringComparer.Ordinal);
|
||
var settings = AddressableAssetSettingsDefaultObject.Settings;
|
||
if (settings == null) return addresses;
|
||
foreach (var group in settings.groups)
|
||
{
|
||
if (group == null) continue;
|
||
foreach (var entry in group.entries)
|
||
if (entry != null) addresses.Add(entry.address);
|
||
}
|
||
return addresses;
|
||
}
|
||
|
||
// ── Ping Addressable ──────────────────────────────────────────────
|
||
|
||
private static void PingAddressableAsset(string address)
|
||
{
|
||
#if UNITY_EDITOR
|
||
var guids = AssetDatabase.FindAssets($"\"{address}\"");
|
||
if (guids.Length > 0)
|
||
{
|
||
var obj = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(AssetDatabase.GUIDToAssetPath(guids[0]));
|
||
if (obj != null) EditorGUIUtility.PingObject(obj);
|
||
}
|
||
#endif
|
||
}
|
||
|
||
// ── Data ──────────────────────────────────────────────────────────
|
||
|
||
private class KeyEntry
|
||
{
|
||
public string FieldName;
|
||
public string Value;
|
||
public bool ExistsInAddressables;
|
||
public List<string> ReferencedInFiles;
|
||
public int ReferenceCount => ReferencedInFiles?.Count ?? 0;
|
||
}
|
||
}
|
||
}
|