From bc8bf2c183c1bc31c4d4e3d6cb74c23dfa9ba09c Mon Sep 17 00:00:00 2001 From: Ding Yuntian <1491671119@qq.com> Date: Fri, 10 Apr 2026 13:22:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E6=B7=BB=E5=8A=A0HandlerNameCo?= =?UTF-8?q?llector=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/Editor/HandlerNameCollector.meta | 8 + Assets/Editor/HandlerNameCollector/Cache.meta | 8 + .../HandlerNameCollector/HandlerNameCache.cs | 221 +++++++ .../HandlerNameCache.cs.meta | 11 + .../HandlerNameCollectorWindow.cs | 597 ++++++++++++++++++ .../HandlerNameCollectorWindow.cs.meta | 11 + .../YamlHandlerNameParser.cs | 441 +++++++++++++ .../YamlHandlerNameParser.cs.meta | 11 + 8 files changed, 1308 insertions(+) create mode 100644 Assets/Editor/HandlerNameCollector.meta create mode 100644 Assets/Editor/HandlerNameCollector/Cache.meta create mode 100644 Assets/Editor/HandlerNameCollector/HandlerNameCache.cs create mode 100644 Assets/Editor/HandlerNameCollector/HandlerNameCache.cs.meta create mode 100644 Assets/Editor/HandlerNameCollector/HandlerNameCollectorWindow.cs create mode 100644 Assets/Editor/HandlerNameCollector/HandlerNameCollectorWindow.cs.meta create mode 100644 Assets/Editor/HandlerNameCollector/YamlHandlerNameParser.cs create mode 100644 Assets/Editor/HandlerNameCollector/YamlHandlerNameParser.cs.meta diff --git a/Assets/Editor/HandlerNameCollector.meta b/Assets/Editor/HandlerNameCollector.meta new file mode 100644 index 000000000..79e05854e --- /dev/null +++ b/Assets/Editor/HandlerNameCollector.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7c4e9a2f1b8d4036a5e7c0d1f2b3a498 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/HandlerNameCollector/Cache.meta b/Assets/Editor/HandlerNameCollector/Cache.meta new file mode 100644 index 000000000..fa5e1700b --- /dev/null +++ b/Assets/Editor/HandlerNameCollector/Cache.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1c5d4e6f708192031425d6e7f8091a2b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/HandlerNameCollector/HandlerNameCache.cs b/Assets/Editor/HandlerNameCollector/HandlerNameCache.cs new file mode 100644 index 000000000..8d5953ddd --- /dev/null +++ b/Assets/Editor/HandlerNameCollector/HandlerNameCache.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace AibisDream.EditorTools +{ + public enum HandlerEntryKind + { + DirectorHandler = 0, + AnimatorHandler = 1 + } + + [Serializable] + public class HandlerNameCacheEntry + { + public string guid; + public string assetPath; + public string fileHash; + public string lastModified; + public string registeredName; + public HandlerEntryKind kind; + public string gameObjectPath; + public bool isInScene; + public int lineNumber; + } + + [Serializable] + public class HandlerNameCache + { + public const int CurrentVersion = 2; + + public int version = CurrentVersion; + public string lastFullScanTime; + public List entries = new List(); + + private static readonly string CacheFileName = "handler_name_cache.json"; + + public static string CacheFolderFullPath => + Path.Combine(Application.dataPath, "Editor/HandlerNameCollector/Cache"); + + public static string CacheFileFullPath => + Path.Combine(CacheFolderFullPath, CacheFileName); + + /// 迁移用:旧版 TimelineNameCollector 缓存路径。 + private static string LegacyTimelineCacheFileFullPath => + Path.Combine(Application.dataPath, "Editor/TimelineNameCollector/Cache/timeline_name_cache.json"); + + public static HandlerNameCache Load() + { + try + { + string json = null; + if (File.Exists(CacheFileFullPath)) + json = File.ReadAllText(CacheFileFullPath, Encoding.UTF8); + else if (File.Exists(LegacyTimelineCacheFileFullPath)) + json = File.ReadAllText(LegacyTimelineCacheFileFullPath, Encoding.UTF8); + + if (string.IsNullOrEmpty(json)) + { + return NewEmpty(); + } + + json = MigrateLegacyCacheJson(json); + var cache = JsonConvert.DeserializeObject(json); + if (cache == null) + cache = NewEmpty(); + + cache.version = CurrentVersion; + cache.entries ??= new List(); + + return cache; + } + catch (Exception e) + { + Debug.LogError($"[HandlerNameCache] Failed to load cache: {e}"); + return NewEmpty(); + } + } + + private static HandlerNameCache NewEmpty() + { + return new HandlerNameCache + { + version = CurrentVersion, + lastFullScanTime = null, + entries = new List() + }; + } + + /// + /// 将旧版 timelineName 字段、缺省 kind 等合并为当前 JSON 结构。 + /// + private static string MigrateLegacyCacheJson(string json) + { + try + { + var jo = JObject.Parse(json); + if (jo["entries"] is JArray arr) + { + foreach (var token in arr) + { + if (token is not JObject item) + continue; + + var reg = item["registeredName"]?.ToString(); + if (string.IsNullOrWhiteSpace(reg)) + { + var legacy = item["timelineName"]?.ToString(); + if (!string.IsNullOrEmpty(legacy)) + item["registeredName"] = legacy; + } + + item.Remove("timelineName"); + + if (item["kind"] == null) + item["kind"] = (int)HandlerEntryKind.DirectorHandler; + } + } + + jo["version"] = CurrentVersion; + return jo.ToString(Formatting.None); + } + catch (Exception e) + { + Debug.LogWarning($"[HandlerNameCache] JSON migration fallback: {e}"); + return json; + } + } + + public void Save() + { + try + { + if (!Directory.Exists(CacheFolderFullPath)) + Directory.CreateDirectory(CacheFolderFullPath); + + entries ??= new List(); + + var settings = new JsonSerializerSettings + { + Formatting = Formatting.Indented, + StringEscapeHandling = StringEscapeHandling.Default + }; + var json = JsonConvert.SerializeObject(this, settings); + File.WriteAllText(CacheFileFullPath, json, Encoding.UTF8); + } + catch (Exception e) + { + Debug.LogError($"[HandlerNameCache] Failed to save cache: {e}"); + } + } + + public void SetLastFullScanNow() + { + lastFullScanTime = DateTime.UtcNow.ToString("o"); + } + + public static string ComputeFileHash(string fullPath) + { + try + { + if (!File.Exists(fullPath)) + return null; + + using (var stream = File.OpenRead(fullPath)) + using (var md5 = MD5.Create()) + { + var hash = md5.ComputeHash(stream); + var sb = new StringBuilder(hash.Length * 2); + foreach (var b in hash) + sb.Append(b.ToString("x2")); + return sb.ToString(); + } + } + catch (Exception e) + { + Debug.LogError($"[HandlerNameCache] Failed to compute file hash for '{fullPath}': {e}"); + return null; + } + } + + public static string GetAssetFullPath(string assetPath) + { + if (string.IsNullOrEmpty(assetPath)) + return null; + + if (!assetPath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) && + !assetPath.Equals("Assets", StringComparison.OrdinalIgnoreCase)) + return null; + + var relative = assetPath.Substring("Assets".Length) + .TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return Path.Combine(Application.dataPath, relative); + } + + public void RemoveEntriesForAsset(string assetPath) + { + if (entries == null || string.IsNullOrEmpty(assetPath)) + return; + entries.RemoveAll(e => e != null && e.assetPath == assetPath); + } + + public void CleanupDeletedAssets() + { + if (entries == null) + return; + entries.RemoveAll(e => + { + if (e == null || string.IsNullOrEmpty(e.assetPath)) + return true; + return AssetDatabase.LoadMainAssetAtPath(e.assetPath) == null; + }); + } + } +} diff --git a/Assets/Editor/HandlerNameCollector/HandlerNameCache.cs.meta b/Assets/Editor/HandlerNameCollector/HandlerNameCache.cs.meta new file mode 100644 index 000000000..45efa9eee --- /dev/null +++ b/Assets/Editor/HandlerNameCollector/HandlerNameCache.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8f2a1c3d4e5b60718293a4b5c6d7e8f0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/HandlerNameCollector/HandlerNameCollectorWindow.cs b/Assets/Editor/HandlerNameCollector/HandlerNameCollectorWindow.cs new file mode 100644 index 000000000..4f0920522 --- /dev/null +++ b/Assets/Editor/HandlerNameCollector/HandlerNameCollectorWindow.cs @@ -0,0 +1,597 @@ +using System; +using System.Collections.Generic; +using System.IO; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace AibisDream.EditorTools +{ + public class HandlerNameCollectorWindow : EditorWindow + { + private const string WindowTitle = "Handler Name Collector"; + + private HandlerNameCache _cache; + + private Vector2 _scrollPos; + private string _searchText = string.Empty; + private bool _showScenes = true; + private bool _showPrefabs = true; + private bool _showConflictsOnly; + + private HandlerEntryKind _viewKind = HandlerEntryKind.DirectorHandler; + + private readonly List _sortedEntries = new List(); + private readonly Dictionary> _entriesByName = + new Dictionary>(StringComparer.OrdinalIgnoreCase); + + private readonly HashSet _conflictNames = + new HashSet(StringComparer.OrdinalIgnoreCase); + + private HandlerNameCacheEntry _selectedEntry; + + [MenuItem("Tools/Handler Name Collector")] + public static void ShowWindow() + { + var window = GetWindow(WindowTitle); + window.minSize = new Vector2(700, 400); + window.Show(); + } + + [MenuItem("Tools/Timeline Name Collector", false, 101)] + public static void ShowWindowLegacyMenu() + { + ShowWindow(); + } + + private void OnEnable() + { + LoadCacheIfNeeded(); + RebuildIndexes(); + } + + private void LoadCacheIfNeeded() + { + _cache ??= HandlerNameCache.Load(); + _cache.entries ??= new List(); + } + + private void RebuildIndexes() + { + _sortedEntries.Clear(); + _entriesByName.Clear(); + _conflictNames.Clear(); + + if (_cache == null || _cache.entries == null) + return; + + foreach (var entry in _cache.entries) + { + if (entry == null || entry.kind != _viewKind) + continue; + + _sortedEntries.Add(entry); + + if (!_entriesByName.TryGetValue(entry.registeredName, out var list)) + { + list = new List(); + _entriesByName[entry.registeredName] = list; + } + + list.Add(entry); + } + + _sortedEntries.Sort((a, b) => + { + var nameCompare = + string.Compare(a.registeredName, b.registeredName, StringComparison.OrdinalIgnoreCase); + if (nameCompare != 0) + return nameCompare; + var typeCompare = a.isInScene.CompareTo(b.isInScene); + if (typeCompare != 0) + return -typeCompare; + return string.Compare(a.assetPath, b.assetPath, StringComparison.OrdinalIgnoreCase); + }); + + foreach (var kv in _entriesByName) + { + if (kv.Value.Count > 1) + _conflictNames.Add(kv.Key); + } + + if (_selectedEntry != null && _selectedEntry.kind != _viewKind) + _selectedEntry = null; + } + + private void OnGUI() + { + LoadCacheIfNeeded(); + + DrawToolbar(); + EditorGUILayout.Space(); + DrawViewKindBar(); + EditorGUILayout.Space(); + DrawFilterBar(); + EditorGUILayout.Space(); + DrawSummary(); + EditorGUILayout.Space(); + DrawListArea(); + EditorGUILayout.Space(); + DrawDetailArea(); + } + + private void DrawToolbar() + { + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + + if (GUILayout.Button("刷新选中", EditorStyles.toolbarButton, GUILayout.Width(90))) + RefreshSelected(); + + if (GUILayout.Button("刷新全部", EditorStyles.toolbarButton, GUILayout.Width(90))) + RefreshAll(forceFull: false); + + if (GUILayout.Button("强制全量", EditorStyles.toolbarButton, GUILayout.Width(90))) + RefreshAll(forceFull: true); + + GUILayout.FlexibleSpace(); + + var lastFullScanLabel = string.IsNullOrEmpty(_cache.lastFullScanTime) + ? "未进行全量扫描" + : $"上次全量: {FormatTime(_cache.lastFullScanTime)}"; + GUILayout.Label(lastFullScanLabel, EditorStyles.miniLabel); + + EditorGUILayout.EndHorizontal(); + } + + private void DrawViewKindBar() + { + EditorGUILayout.BeginHorizontal(); + + GUILayout.Label("视图:", GUILayout.Width(40)); + var labels = new[] { "DirectorHandler", "AnimatorHandler" }; + var newIndex = GUILayout.Toolbar( + _viewKind == HandlerEntryKind.DirectorHandler ? 0 : 1, + labels, + GUILayout.Height(22)); + + var newKind = newIndex == 0 ? HandlerEntryKind.DirectorHandler : HandlerEntryKind.AnimatorHandler; + if (newKind != _viewKind) + { + _viewKind = newKind; + _selectedEntry = null; + RebuildIndexes(); + } + + EditorGUILayout.EndHorizontal(); + } + + private void DrawFilterBar() + { + EditorGUILayout.BeginHorizontal(); + + GUILayout.Label("搜索:", GUILayout.Width(40)); + _searchText = EditorGUILayout.TextField(_searchText); + + _showScenes = GUILayout.Toggle(_showScenes, "场景", GUILayout.Width(60)); + _showPrefabs = GUILayout.Toggle(_showPrefabs, "预制体", GUILayout.Width(70)); + _showConflictsOnly = GUILayout.Toggle(_showConflictsOnly, "只看冲突", GUILayout.Width(80)); + + EditorGUILayout.EndHorizontal(); + } + + private void DrawSummary() + { + var total = CountVisibleEntries(); + var conflictCount = _conflictNames.Count; + var modeLabel = _viewKind == HandlerEntryKind.DirectorHandler ? "DirectorHandler" : "AnimatorHandler"; + EditorGUILayout.LabelField( + $"[{modeLabel}] 找到 {total} 个注册名条目({conflictCount} 个重名)", + EditorStyles.boldLabel); + } + + private int CountVisibleEntries() + { + var n = 0; + foreach (var entry in _sortedEntries) + { + if (PassFilter(entry)) + n++; + } + + return n; + } + + private void DrawListArea() + { + EditorGUILayout.LabelField("列表", EditorStyles.boldLabel); + + EditorGUILayout.BeginHorizontal(); + GUILayout.Label("注册名", GUILayout.Width(220)); + GUILayout.Label("类型", GUILayout.Width(50)); + GUILayout.Label("位置", GUILayout.ExpandWidth(true)); + GUILayout.Label("", GUILayout.Width(90)); + EditorGUILayout.EndHorizontal(); + + var visibleCount = CountVisibleEntries(); + var rect = GUILayoutUtility.GetRect(0, 100000, 0, 100000); + _scrollPos = GUI.BeginScrollView(rect, _scrollPos, + new Rect(0, 0, rect.width - 20, visibleCount * 20 + 10)); + + var y = 0f; + var rowHeight = 20f; + var viewWidth = rect.width - 20; + + foreach (var entry in _sortedEntries) + { + if (!PassFilter(entry)) + continue; + + var isConflict = _conflictNames.Contains(entry.registeredName); + var rowRect = new Rect(0, y, viewWidth, rowHeight); + DrawRow(rowRect, entry, isConflict); + y += rowHeight; + } + + GUI.EndScrollView(); + } + + private void DrawRow(Rect rect, HandlerNameCacheEntry entry, bool isConflict) + { + var typeLabel = entry.isInScene ? "场景" : "预制体"; + var location = GetShortLocation(entry); + + var bgColor = GUI.backgroundColor; + if (isConflict) + GUI.backgroundColor = new Color(1f, 0.9f, 0.9f); + + if (Event.current.type == EventType.MouseDown && rect.Contains(Event.current.mousePosition)) + { + if (Event.current.button == 0) + { + _selectedEntry = entry; + Repaint(); + Event.current.Use(); + } + } + + GUI.Box(rect, GUIContent.none, EditorStyles.helpBox); + + GUI.backgroundColor = bgColor; + + var colRect = rect; + colRect.x += 4; + colRect.width = 220; + GUI.Label(colRect, entry.registeredName); + + colRect.x += colRect.width + 4; + colRect.width = 50; + GUI.Label(colRect, typeLabel); + + colRect.x += colRect.width + 4; + colRect.width = rect.width - colRect.x - 100; + GUI.Label(colRect, location); + + colRect.x += colRect.width + 4; + colRect.width = 90; + if (GUI.Button(colRect, "项目中显示")) + PingEntry(entry); + } + + private string RegisteredNameLabel() + { + return _viewKind == HandlerEntryKind.DirectorHandler ? "Timeline 注册名" : "Animator 注册名"; + } + + private void DrawDetailArea() + { + EditorGUILayout.LabelField("选中项详情", EditorStyles.boldLabel); + + if (_selectedEntry == null) + { + EditorGUILayout.HelpBox("在上方列表中点击某一行查看详情。", MessageType.Info); + return; + } + + GUILayout.Label($"{RegisteredNameLabel()}: {_selectedEntry.registeredName}", EditorStyles.boldLabel); + + if (_entriesByName.TryGetValue(_selectedEntry.registeredName, out var group)) + { + foreach (var e in group) + { + EditorGUILayout.BeginVertical("box"); + + EditorGUILayout.LabelField("资源路径", e.assetPath); + EditorGUILayout.LabelField("GameObject", + string.IsNullOrEmpty(e.gameObjectPath) ? "(未知)" : e.gameObjectPath); + EditorGUILayout.LabelField("类型", e.isInScene ? "场景" : "预制体"); + EditorGUILayout.LabelField("行号", e.lineNumber.ToString()); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("在Project窗口显示", GUILayout.Width(140))) + PingEntry(e); + + if (e.isInScene && GUILayout.Button("打开场景并选中", GUILayout.Width(140))) + OpenSceneAndSelect(e); + + if (GUILayout.Button("复制名称", GUILayout.Width(100))) + EditorGUIUtility.systemCopyBuffer = e.registeredName; + + EditorGUILayout.EndHorizontal(); + EditorGUILayout.EndVertical(); + } + } + } + + private bool PassFilter(HandlerNameCacheEntry entry) + { + if (!_showScenes && entry.isInScene) + return false; + if (!_showPrefabs && !entry.isInScene) + return false; + + if (_showConflictsOnly && !_conflictNames.Contains(entry.registeredName)) + return false; + + if (string.IsNullOrEmpty(_searchText)) + return true; + + var s = _searchText.Trim(); + if (s.Length == 0) + return true; + + var cmp = StringComparison.OrdinalIgnoreCase; + + if (!string.IsNullOrEmpty(entry.registeredName) && + entry.registeredName.Contains(s, cmp)) + return true; + + if (!string.IsNullOrEmpty(entry.assetPath) && + entry.assetPath.Contains(s, cmp)) + return true; + + if (!string.IsNullOrEmpty(entry.gameObjectPath) && + entry.gameObjectPath.Contains(s, cmp)) + return true; + + return false; + } + + private static string GetShortLocation(HandlerNameCacheEntry entry) + { + if (string.IsNullOrEmpty(entry.assetPath)) + return string.Empty; + + var fileName = Path.GetFileNameWithoutExtension(entry.assetPath); + + if (string.IsNullOrEmpty(entry.gameObjectPath)) + return fileName; + + return $"{fileName} / {entry.gameObjectPath}"; + } + + private static void PingEntry(HandlerNameCacheEntry entry) + { + if (string.IsNullOrEmpty(entry.assetPath)) + return; + + var obj = AssetDatabase.LoadMainAssetAtPath(entry.assetPath); + if (obj != null) + { + EditorGUIUtility.PingObject(obj); + Selection.activeObject = obj; + } + } + + private static void OpenSceneAndSelect(HandlerNameCacheEntry entry) + { + if (string.IsNullOrEmpty(entry.assetPath)) + return; + + if (!entry.isInScene) + return; + + if (!File.Exists(entry.assetPath)) + { + Debug.LogWarning($"场景文件不存在: {entry.assetPath}"); + return; + } + + var scene = EditorSceneManager.OpenScene(entry.assetPath, OpenSceneMode.Single); + if (!scene.IsValid()) + { + Debug.LogWarning($"无法打开场景: {entry.assetPath}"); + return; + } + + if (string.IsNullOrEmpty(entry.gameObjectPath)) + return; + + var parts = entry.gameObjectPath.Split('/'); + GameObject current = null; + + foreach (var part in parts) + { + if (current == null) + { + var roots = scene.GetRootGameObjects(); + current = Array.Find(roots, go => go.name == part); + } + else + { + var child = current.transform.Find(part); + current = child != null ? child.gameObject : null; + } + + if (current == null) + break; + } + + if (current != null) + { + Selection.activeGameObject = current; + EditorGUIUtility.PingObject(current); + } + else + { + Debug.LogWarning($"在场景 {scene.name} 中未找到路径: {entry.gameObjectPath}"); + } + } + + private void RefreshSelected() + { + LoadCacheIfNeeded(); + + var guids = Selection.assetGUIDs; + if (guids == null || guids.Length == 0) + { + EditorUtility.DisplayDialog(WindowTitle, "请在 Project 窗口中先选中场景或预制体资源。", "确定"); + return; + } + + try + { + EditorUtility.DisplayProgressBar(WindowTitle, "正在刷新选中资源...", 0f); + + var processed = 0; + foreach (var guid in guids) + { + var assetPath = AssetDatabase.GUIDToAssetPath(guid); + if (!IsSupportedAsset(assetPath)) + continue; + + ProcessSingleAsset(guid, assetPath); + + processed++; + EditorUtility.DisplayProgressBar(WindowTitle, $"正在解析: {assetPath}", + processed / (float)guids.Length); + } + } + finally + { + EditorUtility.ClearProgressBar(); + } + + _cache.CleanupDeletedAssets(); + _cache.Save(); + RebuildIndexes(); + Repaint(); + } + + private void RefreshAll(bool forceFull) + { + LoadCacheIfNeeded(); + + var sceneGuids = AssetDatabase.FindAssets("t:Scene"); + var prefabGuids = AssetDatabase.FindAssets("t:Prefab"); + + var allGuids = new HashSet(sceneGuids); + foreach (var g in prefabGuids) + allGuids.Add(g); + + var guidList = new List(allGuids); + guidList.Sort(); + + var existingHashes = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var e in _cache.entries) + { + if (e == null || string.IsNullOrEmpty(e.assetPath) || string.IsNullOrEmpty(e.fileHash)) + continue; + + if (!existingHashes.ContainsKey(e.assetPath)) + existingHashes[e.assetPath] = e.fileHash; + } + + try + { + EditorUtility.DisplayProgressBar(WindowTitle, + forceFull ? "正在强制全量扫描..." : "正在增量扫描...", 0f); + + for (var i = 0; i < guidList.Count; i++) + { + var guid = guidList[i]; + var assetPath = AssetDatabase.GUIDToAssetPath(guid); + if (!IsSupportedAsset(assetPath)) + continue; + + var fullPath = HandlerNameCache.GetAssetFullPath(assetPath); + if (string.IsNullOrEmpty(fullPath) || !File.Exists(fullPath)) + continue; + + var newHash = HandlerNameCache.ComputeFileHash(fullPath); + + var needScan = forceFull; + if (!needScan) + { + if (!existingHashes.TryGetValue(assetPath, out var oldHash) || + string.IsNullOrEmpty(newHash) || + !string.Equals(oldHash, newHash, StringComparison.OrdinalIgnoreCase)) + needScan = true; + } + + if (!needScan) + continue; + + ProcessSingleAsset(guid, assetPath, newHash); + + var progress = (i + 1) / (float)guidList.Count; + EditorUtility.DisplayProgressBar(WindowTitle, $"正在解析: {assetPath}", progress); + } + } + finally + { + EditorUtility.ClearProgressBar(); + } + + _cache.CleanupDeletedAssets(); + _cache.SetLastFullScanNow(); + _cache.Save(); + RebuildIndexes(); + Repaint(); + } + + private void ProcessSingleAsset(string guid, string assetPath, string precomputedHash = null) + { + var fullPath = HandlerNameCache.GetAssetFullPath(assetPath); + if (string.IsNullOrEmpty(fullPath) || !File.Exists(fullPath)) + return; + + var fileHash = precomputedHash ?? HandlerNameCache.ComputeFileHash(fullPath); + var lastModifiedUtc = File.GetLastWriteTimeUtc(fullPath).ToString("o"); + + _cache.RemoveEntriesForAsset(assetPath); + + var entries = YamlHandlerNameParser.ParseAsset( + assetPath, + guid, + fileHash, + lastModifiedUtc); + + if (entries != null && entries.Count > 0) + _cache.entries.AddRange(entries); + } + + private static bool IsSupportedAsset(string assetPath) + { + if (string.IsNullOrEmpty(assetPath)) + return false; + + var ext = Path.GetExtension(assetPath); + if (string.IsNullOrEmpty(ext)) + return false; + + ext = ext.ToLowerInvariant(); + return ext == ".unity" || ext == ".prefab"; + } + + private static string FormatTime(string isoTime) + { + if (string.IsNullOrEmpty(isoTime)) + return "-"; + + if (DateTime.TryParse(isoTime, out var dt)) + return dt.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"); + + return isoTime; + } + } +} diff --git a/Assets/Editor/HandlerNameCollector/HandlerNameCollectorWindow.cs.meta b/Assets/Editor/HandlerNameCollector/HandlerNameCollectorWindow.cs.meta new file mode 100644 index 000000000..14cf50b1f --- /dev/null +++ b/Assets/Editor/HandlerNameCollector/HandlerNameCollectorWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0b4c3d5e6f7081920314c5d6e7f8091a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/HandlerNameCollector/YamlHandlerNameParser.cs b/Assets/Editor/HandlerNameCollector/YamlHandlerNameParser.cs new file mode 100644 index 000000000..e887ebec2 --- /dev/null +++ b/Assets/Editor/HandlerNameCollector/YamlHandlerNameParser.cs @@ -0,0 +1,441 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; +using UnityEditor; +using UnityEngine; + +namespace AibisDream.EditorTools +{ + /// + /// 解析 Unity YAML 场景 / 预制体中的 DirectorHandler、AnimatorHandler,提取注册名与 GameObject 路径。 + /// + public static class YamlHandlerNameParser + { + // DirectorHandler.cs.meta + public const string DirectorHandlerScriptGuid = "07d657d6b80509b4eb04f59afaa9aa2d"; + + // AnimatorHandler.cs.meta + public const string AnimatorHandlerScriptGuid = "babec43795065b244ac4cef4af7fa225"; + + private const int ClassIdGameObject = 1; + private const int ClassIdTransform = 4; + private const int ClassIdMonoBehaviour = 114; + + private static readonly Regex HeaderRegex = + new Regex(@"^--- !u!(\d+) &(\d+)", RegexOptions.Compiled); + + private static readonly Regex FileIdRegex = + new Regex(@"fileID:\s*(\d+)", RegexOptions.Compiled); + + private class YamlObject + { + public int classId; + public long fileId; + public int startLine; + public int endLine; + } + + private class TransformInfo + { + public long transformFileId; + public long parentTransformFileId; + public long gameObjectFileId; + } + + public static List ParseAsset( + string assetPath, + string guid, + string fileHash, + string lastModifiedUtc) + { + var result = new List(); + + if (string.IsNullOrEmpty(assetPath)) + return result; + + var fullPath = HandlerNameCache.GetAssetFullPath(assetPath); + if (string.IsNullOrEmpty(fullPath) || !File.Exists(fullPath)) + return result; + + string[] lines; + try + { + lines = File.ReadAllLines(fullPath); + } + catch (Exception e) + { + Debug.LogError($"[YamlHandlerNameParser] Failed to read '{fullPath}': {e}"); + return result; + } + + if (lines.Length == 0 || !lines[0].StartsWith("%YAML", StringComparison.Ordinal)) + return result; + + var objects = BuildObjects(lines); + if (objects.Count == 0) + return result; + + var gameObjectNames = new Dictionary(); + var transforms = new Dictionary(); + var transformByGameObject = new Dictionary(); + + foreach (var obj in objects) + { + switch (obj.classId) + { + case ClassIdGameObject: + var name = ExtractGameObjectName(lines, obj); + if (!string.IsNullOrEmpty(name)) + gameObjectNames[obj.fileId] = name; + break; + + case ClassIdTransform: + var tInfo = ExtractTransformInfo(lines, obj); + if (tInfo != null) + { + transforms[tInfo.transformFileId] = tInfo; + if (tInfo.gameObjectFileId != 0 && + !transformByGameObject.ContainsKey(tInfo.gameObjectFileId)) + transformByGameObject[tInfo.gameObjectFileId] = tInfo.transformFileId; + } + + break; + } + } + + foreach (var obj in objects) + { + if (obj.classId != ClassIdMonoBehaviour) + continue; + + if (IsDirectorHandler(lines, obj)) + { + var monoInfo = ExtractDirectorHandlerInfo( + lines, + obj, + assetPath, + guid, + fileHash, + lastModifiedUtc, + gameObjectNames, + transforms, + transformByGameObject); + if (monoInfo != null) + result.Add(monoInfo); + } + else if (IsAnimatorHandler(lines, obj)) + { + var monoInfo = ExtractAnimatorHandlerInfo( + lines, + obj, + assetPath, + guid, + fileHash, + lastModifiedUtc, + gameObjectNames, + transforms, + transformByGameObject); + if (monoInfo != null) + result.Add(monoInfo); + } + } + + return result; + } + + private static List BuildObjects(string[] lines) + { + var objects = new List(); + YamlObject current = null; + + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i]; + var match = HeaderRegex.Match(line); + if (!match.Success) + continue; + + if (current != null) + { + current.endLine = i - 1; + objects.Add(current); + } + + if (!int.TryParse(match.Groups[1].Value, out var classId)) + continue; + + if (!long.TryParse(match.Groups[2].Value, out var fileId)) + continue; + + current = new YamlObject + { + classId = classId, + fileId = fileId, + startLine = i, + endLine = i + }; + } + + if (current != null) + { + current.endLine = lines.Length - 1; + objects.Add(current); + } + + return objects; + } + + private static string ExtractGameObjectName(string[] lines, YamlObject obj) + { + for (var i = obj.startLine; i <= obj.endLine; i++) + { + var line = lines[i]; + var trimmed = line.TrimStart(); + if (!trimmed.StartsWith("m_Name:", StringComparison.Ordinal)) + continue; + + var idx = trimmed.IndexOf(':'); + if (idx < 0 || idx + 1 >= trimmed.Length) + break; + + var name = trimmed.Substring(idx + 1).Trim(); + name = name.Trim('"'); + return UnescapeUnityYamlString(name); + } + + return null; + } + + private static TransformInfo ExtractTransformInfo(string[] lines, YamlObject obj) + { + long gameObjectFileId = 0; + long parentTransformFileId = 0; + + for (var i = obj.startLine; i <= obj.endLine; i++) + { + var line = lines[i].TrimStart(); + + if (line.StartsWith("m_GameObject:", StringComparison.Ordinal)) + { + var match = FileIdRegex.Match(line); + if (match.Success && long.TryParse(match.Groups[1].Value, out var id)) + gameObjectFileId = id; + } + else if (line.StartsWith("m_Father:", StringComparison.Ordinal)) + { + var match = FileIdRegex.Match(line); + if (match.Success && long.TryParse(match.Groups[1].Value, out var id)) + parentTransformFileId = id; + } + } + + if (gameObjectFileId == 0) + return null; + + return new TransformInfo + { + transformFileId = obj.fileId, + parentTransformFileId = parentTransformFileId, + gameObjectFileId = gameObjectFileId + }; + } + + private static bool IsDirectorHandler(string[] lines, YamlObject obj) + { + for (var i = obj.startLine; i <= obj.endLine; i++) + { + var line = lines[i].TrimStart(); + if (!line.StartsWith("m_Script:", StringComparison.Ordinal)) + continue; + + if (line.Contains(DirectorHandlerScriptGuid, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + + private static bool IsAnimatorHandler(string[] lines, YamlObject obj) + { + for (var i = obj.startLine; i <= obj.endLine; i++) + { + var line = lines[i].TrimStart(); + if (!line.StartsWith("m_Script:", StringComparison.Ordinal)) + continue; + + if (line.Contains(AnimatorHandlerScriptGuid, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + + private static HandlerNameCacheEntry ExtractDirectorHandlerInfo( + string[] lines, + YamlObject obj, + string assetPath, + string guid, + string fileHash, + string lastModifiedUtc, + Dictionary gameObjectNames, + Dictionary transforms, + Dictionary transformByGameObject) + { + long gameObjectFileId = 0; + string timelineName = null; + var nameLine = -1; + + for (var i = obj.startLine; i <= obj.endLine; i++) + { + var line = lines[i].TrimStart(); + + if (line.StartsWith("m_GameObject:", StringComparison.Ordinal)) + { + var match = FileIdRegex.Match(line); + if (match.Success && long.TryParse(match.Groups[1].Value, out var id)) + gameObjectFileId = id; + } + else if (line.StartsWith("timelineName:", StringComparison.Ordinal)) + { + var idx = line.IndexOf(':'); + if (idx >= 0 && idx + 1 < line.Length) + { + var value = line.Substring(idx + 1).Trim(); + value = value.Trim('"'); + value = value.Trim('\''); + timelineName = UnescapeUnityYamlString(value); + nameLine = i + 1; + } + } + } + + if (string.IsNullOrEmpty(timelineName)) + return null; + + var isInScene = assetPath.EndsWith(".unity", StringComparison.OrdinalIgnoreCase); + var goPath = BuildGameObjectPath(gameObjectFileId, gameObjectNames, transforms, transformByGameObject); + + return new HandlerNameCacheEntry + { + guid = guid, + assetPath = assetPath, + fileHash = fileHash, + lastModified = lastModifiedUtc, + registeredName = timelineName, + kind = HandlerEntryKind.DirectorHandler, + gameObjectPath = goPath, + isInScene = isInScene, + lineNumber = nameLine > 0 ? nameLine : obj.startLine + 1 + }; + } + + private static HandlerNameCacheEntry ExtractAnimatorHandlerInfo( + string[] lines, + YamlObject obj, + string assetPath, + string guid, + string fileHash, + string lastModifiedUtc, + Dictionary gameObjectNames, + Dictionary transforms, + Dictionary transformByGameObject) + { + long gameObjectFileId = 0; + string animatorName = null; + var nameLine = -1; + + for (var i = obj.startLine; i <= obj.endLine; i++) + { + var line = lines[i].TrimStart(); + + if (line.StartsWith("m_GameObject:", StringComparison.Ordinal)) + { + var match = FileIdRegex.Match(line); + if (match.Success && long.TryParse(match.Groups[1].Value, out var id)) + gameObjectFileId = id; + } + else if (line.StartsWith("animatorName:", StringComparison.Ordinal)) + { + var idx = line.IndexOf(':'); + if (idx >= 0 && idx + 1 < line.Length) + { + var value = line.Substring(idx + 1).Trim(); + value = value.Trim('"'); + value = value.Trim('\''); + animatorName = UnescapeUnityYamlString(value); + nameLine = i + 1; + } + } + } + + if (string.IsNullOrEmpty(animatorName)) + return null; + + var isInScene = assetPath.EndsWith(".unity", StringComparison.OrdinalIgnoreCase); + var goPath = BuildGameObjectPath(gameObjectFileId, gameObjectNames, transforms, transformByGameObject); + + return new HandlerNameCacheEntry + { + guid = guid, + assetPath = assetPath, + fileHash = fileHash, + lastModified = lastModifiedUtc, + registeredName = animatorName, + kind = HandlerEntryKind.AnimatorHandler, + gameObjectPath = goPath, + isInScene = isInScene, + lineNumber = nameLine > 0 ? nameLine : obj.startLine + 1 + }; + } + + private static string BuildGameObjectPath( + long gameObjectFileId, + Dictionary gameObjectNames, + Dictionary transforms, + Dictionary transformByGameObject) + { + if (gameObjectFileId == 0) + return string.Empty; + + if (!transformByGameObject.TryGetValue(gameObjectFileId, out var transformId)) + { + gameObjectNames.TryGetValue(gameObjectFileId, out var nameOnly); + return nameOnly ?? string.Empty; + } + + var segments = new List(); + var currentTransformId = transformId; + var safety = 0; + + while (currentTransformId != 0 && safety++ < 256) + { + if (!transforms.TryGetValue(currentTransformId, out var tInfo)) + break; + + if (!gameObjectNames.TryGetValue(tInfo.gameObjectFileId, out var name)) + name = "GameObject"; + + segments.Add(name); + currentTransformId = tInfo.parentTransformFileId; + } + + segments.Reverse(); + return string.Join("/", segments); + } + + private static string UnescapeUnityYamlString(string value) + { + if (string.IsNullOrEmpty(value)) + return value; + try + { + return Regex.Unescape(value); + } + catch + { + return value; + } + } + } +} diff --git a/Assets/Editor/HandlerNameCollector/YamlHandlerNameParser.cs.meta b/Assets/Editor/HandlerNameCollector/YamlHandlerNameParser.cs.meta new file mode 100644 index 000000000..057239c1d --- /dev/null +++ b/Assets/Editor/HandlerNameCollector/YamlHandlerNameParser.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9a3b2c4d5e6f70819203b4c5d6e7f809 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: