diff --git a/Assets/Editor/TimelineNameCollector.meta b/Assets/Editor/TimelineNameCollector.meta new file mode 100644 index 000000000..b7deffdd6 --- /dev/null +++ b/Assets/Editor/TimelineNameCollector.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9292c6c3670fe4845bdd16bcdd60772c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/TimelineNameCollector/Cache.meta b/Assets/Editor/TimelineNameCollector/Cache.meta new file mode 100644 index 000000000..ff4a0f130 --- /dev/null +++ b/Assets/Editor/TimelineNameCollector/Cache.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3961ba56a1773164f8605d3a1413e07f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/TimelineNameCollector/Cache/timeline_name_cache.json b/Assets/Editor/TimelineNameCollector/Cache/timeline_name_cache.json new file mode 100644 index 000000000..303b20754 --- /dev/null +++ b/Assets/Editor/TimelineNameCollector/Cache/timeline_name_cache.json @@ -0,0 +1,66 @@ +{ + "version": 1, + "lastFullScanTime": "2026-03-05T11:00:30.3660890Z", + "entries": [ + { + "guid": "30073b2193480e3469efae4338cb89b6", + "assetPath": "Assets/Scenes/梦境.unity", + "fileHash": "31d334e788325b31cd0654c54743ffeb", + "lastModified": "2026-03-02T12:10:00.4902246Z", + "timelineName": "星空到海浪", + "gameObjectPath": "Timeline Kit/场景动画", + "isInScene": true, + "lineNumber": 214 + }, + { + "guid": "30073b2193480e3469efae4338cb89b6", + "assetPath": "Assets/Scenes/梦境.unity", + "fileHash": "31d334e788325b31cd0654c54743ffeb", + "lastModified": "2026-03-02T12:10:00.4902246Z", + "timelineName": "惊醒", + "gameObjectPath": "Timeline Kit/惊醒动画", + "isInScene": true, + "lineNumber": 772 + }, + { + "guid": "4b35a149a0fc9214faaf88701fde8c77", + "assetPath": "Assets/Prefabs/Timeline管理器.prefab", + "fileHash": "374f91947d5d02157b5f0dbedb3b94e7", + "lastModified": "2026-02-28T09:12:58.1420437Z", + "timelineName": "调酒蒙太奇", + "gameObjectPath": "Timeline管理器/调酒蒙太奇", + "isInScene": false, + "lineNumber": 142 + }, + { + "guid": "5b2e098922156b645a82b7bcdb436ac9", + "assetPath": "Assets/Prefabs/FixSystemPrefabs/Block Puzzle Game.prefab", + "fileHash": "9ebe7cd4896117ac24c42a768106ea2c", + "lastModified": "2026-03-04T11:40:07.6734919Z", + "timelineName": "卡扣", + "gameObjectPath": "Block Puzzle Game/卡扣", + "isInScene": false, + "lineNumber": 126 + }, + { + "guid": "7f4c602d6453eb741b937d0efdb26f9d", + "assetPath": "Assets/Prefabs/FixSystemPrefabs/维修面板.prefab", + "fileHash": "5c65923c555414b1f72fe607f88f368d", + "lastModified": "2026-03-05T08:21:10.5195665Z", + "timelineName": "插线面板", + "gameObjectPath": "维修面板/场景部分/插线维修界面", + "isInScene": false, + "lineNumber": 4758 + }, + { + "guid": "7f4c602d6453eb741b937d0efdb26f9d", + "assetPath": "Assets/Prefabs/FixSystemPrefabs/维修面板.prefab", + "fileHash": "5c65923c555414b1f72fe607f88f368d", + "lastModified": "2026-03-05T08:21:10.5195665Z", + "timelineName": "引擎仓面板", + "gameObjectPath": "维修面板/场景部分/引擎仓维修面板", + "isInScene": false, + "lineNumber": 4913 + } + ] +} \ No newline at end of file diff --git a/Assets/Editor/TimelineNameCollector/Cache/timeline_name_cache.json.meta b/Assets/Editor/TimelineNameCollector/Cache/timeline_name_cache.json.meta new file mode 100644 index 000000000..62cd32008 --- /dev/null +++ b/Assets/Editor/TimelineNameCollector/Cache/timeline_name_cache.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 65cb908453570044abbc145dac80f6de +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/TimelineNameCollector/TimelineNameCache.cs b/Assets/Editor/TimelineNameCollector/TimelineNameCache.cs new file mode 100644 index 000000000..c79daf725 --- /dev/null +++ b/Assets/Editor/TimelineNameCollector/TimelineNameCache.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using Newtonsoft.Json; +using UnityEditor; +using UnityEngine; + +namespace AibisDream.EditorTools +{ + [Serializable] + public class TimelineNameCacheEntry + { + public string guid; + public string assetPath; + public string fileHash; + public string lastModified; + public string timelineName; + public string gameObjectPath; + public bool isInScene; + public int lineNumber; + } + + [Serializable] + public class TimelineNameCache + { + public const int CurrentVersion = 1; + + public int version = CurrentVersion; + public string lastFullScanTime; + public List entries = new List(); + + private static readonly string CacheFolderRelativePath = "Assets/Editor/TimelineNameCollector/Cache"; + private static readonly string CacheFileName = "timeline_name_cache.json"; + + public static string CacheFolderFullPath => + Path.Combine(Application.dataPath, "Editor/TimelineNameCollector/Cache"); + + public static string CacheFileFullPath => + Path.Combine(CacheFolderFullPath, CacheFileName); + + public static TimelineNameCache Load() + { + try + { + if (!File.Exists(CacheFileFullPath)) + { + return new TimelineNameCache + { + version = CurrentVersion, + lastFullScanTime = null, + entries = new List() + }; + } + + var json = File.ReadAllText(CacheFileFullPath, Encoding.UTF8); + if (string.IsNullOrEmpty(json)) + { + return new TimelineNameCache + { + version = CurrentVersion, + lastFullScanTime = null, + entries = new List() + }; + } + + var cache = JsonConvert.DeserializeObject(json); + if (cache == null) + { + cache = new TimelineNameCache(); + } + + if (cache.version != CurrentVersion) + { + cache.version = CurrentVersion; + cache.lastFullScanTime = null; + cache.entries = cache.entries ?? new List(); + } + + return cache; + } + catch (Exception e) + { + Debug.LogError($"[TimelineNameCache] Failed to load cache: {e}"); + return new TimelineNameCache + { + version = CurrentVersion, + lastFullScanTime = null, + entries = new List() + }; + } + } + + 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($"[TimelineNameCache] 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($"[TimelineNameCache] 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/TimelineNameCollector/TimelineNameCache.cs.meta b/Assets/Editor/TimelineNameCollector/TimelineNameCache.cs.meta new file mode 100644 index 000000000..2e5d3561f --- /dev/null +++ b/Assets/Editor/TimelineNameCollector/TimelineNameCache.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 58e8fb78cff00f44482eff9d6a1b7a64 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/TimelineNameCollector/TimelineNameCollectorWindow.cs b/Assets/Editor/TimelineNameCollector/TimelineNameCollectorWindow.cs new file mode 100644 index 000000000..df624103a --- /dev/null +++ b/Assets/Editor/TimelineNameCollector/TimelineNameCollectorWindow.cs @@ -0,0 +1,567 @@ +using System; +using System.Collections.Generic; +using System.IO; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace AibisDream.EditorTools +{ + public class TimelineNameCollectorWindow : EditorWindow + { + private TimelineNameCache _cache; + + private Vector2 _scrollPos; + private string _searchText = string.Empty; + private bool _showScenes = true; + private bool _showPrefabs = true; + private bool _showConflictsOnly; + + private readonly List _sortedEntries = new List(); + private readonly Dictionary> _entriesByName = + new Dictionary>(StringComparer.OrdinalIgnoreCase); + + private readonly HashSet _conflictNames = + new HashSet(StringComparer.OrdinalIgnoreCase); + + private TimelineNameCacheEntry _selectedEntry; + + [MenuItem("Tools/Timeline Name Collector")] + public static void ShowWindow() + { + var window = GetWindow("Timeline Name Collector"); + window.minSize = new Vector2(700, 400); + window.Show(); + } + + private void OnEnable() + { + LoadCacheIfNeeded(); + RebuildIndexes(); + } + + private void LoadCacheIfNeeded() + { + _cache ??= TimelineNameCache.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) + continue; + + _sortedEntries.Add(entry); + + if (!_entriesByName.TryGetValue(entry.timelineName, out var list)) + { + list = new List(); + _entriesByName[entry.timelineName] = list; + } + list.Add(entry); + } + + _sortedEntries.Sort((a, b) => + { + var nameCompare = string.Compare(a.timelineName, b.timelineName, 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); + } + } + } + + private void OnGUI() + { + LoadCacheIfNeeded(); + + DrawToolbar(); + 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 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 = _sortedEntries.Count; + var conflictCount = _conflictNames.Count; + EditorGUILayout.LabelField($"找到 {total} 个 TimelineName 条目({conflictCount} 个重名)", + EditorStyles.boldLabel); + } + + private void DrawListArea() + { + EditorGUILayout.LabelField("列表", EditorStyles.boldLabel); + + EditorGUILayout.BeginHorizontal(); + GUILayout.Label("Timeline Name", GUILayout.Width(220)); + GUILayout.Label("类型", GUILayout.Width(50)); + GUILayout.Label("位置", GUILayout.ExpandWidth(true)); + GUILayout.Label("", GUILayout.Width(90)); + EditorGUILayout.EndHorizontal(); + + var rect = GUILayoutUtility.GetRect(0, 100000, 0, 100000); + _scrollPos = GUI.BeginScrollView(rect, _scrollPos, new Rect(0, 0, rect.width - 20, _sortedEntries.Count * 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.timelineName); + var rowRect = new Rect(0, y, viewWidth, rowHeight); + DrawRow(rowRect, entry, isConflict); + y += rowHeight; + } + + GUI.EndScrollView(); + } + + private void DrawRow(Rect rect, TimelineNameCacheEntry 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.timelineName); + + 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 void DrawDetailArea() + { + EditorGUILayout.LabelField("选中项详情", EditorStyles.boldLabel); + + if (_selectedEntry == null) + { + EditorGUILayout.HelpBox("在上方列表中点击某一行查看详情。", MessageType.Info); + return; + } + + GUILayout.Label($"Timeline Name: {_selectedEntry.timelineName}", EditorStyles.boldLabel); + + if (_entriesByName.TryGetValue(_selectedEntry.timelineName, 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.timelineName; + } + + EditorGUILayout.EndHorizontal(); + EditorGUILayout.EndVertical(); + } + } + } + + private bool PassFilter(TimelineNameCacheEntry entry) + { + if (!_showScenes && entry.isInScene) + return false; + if (!_showPrefabs && !entry.isInScene) + return false; + + if (_showConflictsOnly && !_conflictNames.Contains(entry.timelineName)) + 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.timelineName) && + entry.timelineName.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(TimelineNameCacheEntry 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(TimelineNameCacheEntry 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(TimelineNameCacheEntry 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("Timeline Name Collector", "请在 Project 窗口中先选中场景或预制体资源。", "确定"); + return; + } + + try + { + EditorUtility.DisplayProgressBar("Timeline Name Collector", "正在刷新选中资源...", 0f); + + var processed = 0; + foreach (var guid in guids) + { + var assetPath = AssetDatabase.GUIDToAssetPath(guid); + if (!IsSupportedAsset(assetPath)) + continue; + + ProcessSingleAsset(guid, assetPath); + + processed++; + EditorUtility.DisplayProgressBar( + "Timeline Name Collector", + $"正在解析: {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(); + + // 现有文件哈希,按 assetPath 去重 + 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("Timeline Name Collector", + 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 = TimelineNameCache.GetAssetFullPath(assetPath); + if (string.IsNullOrEmpty(fullPath) || !File.Exists(fullPath)) + continue; + + var newHash = TimelineNameCache.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("Timeline Name Collector", + $"正在解析: {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 = TimelineNameCache.GetAssetFullPath(assetPath); + if (string.IsNullOrEmpty(fullPath) || !File.Exists(fullPath)) + return; + + var fileHash = precomputedHash ?? TimelineNameCache.ComputeFileHash(fullPath); + var lastModifiedUtc = File.GetLastWriteTimeUtc(fullPath).ToString("o"); + + _cache.RemoveEntriesForAsset(assetPath); + + var entries = YamlTimelineParser.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/TimelineNameCollector/TimelineNameCollectorWindow.cs.meta b/Assets/Editor/TimelineNameCollector/TimelineNameCollectorWindow.cs.meta new file mode 100644 index 000000000..b63134680 --- /dev/null +++ b/Assets/Editor/TimelineNameCollector/TimelineNameCollectorWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6a43798aefb6a4142a7abe8d3dc1649a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/TimelineNameCollector/YamlTimelineParser.cs b/Assets/Editor/TimelineNameCollector/YamlTimelineParser.cs new file mode 100644 index 000000000..8753e7adf --- /dev/null +++ b/Assets/Editor/TimelineNameCollector/YamlTimelineParser.cs @@ -0,0 +1,375 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; +using UnityEditor; +using UnityEngine; + +namespace AibisDream.EditorTools +{ + /// + /// 解析 Unity YAML 场景 / 预制体中的 DirectorHandler 组件,提取 timelineName 和 GameObject 路径。 + /// 解析方式:按 "--- !u! &" 分段,构建 GameObject / Transform / MonoBehaviour 的索引。 + /// + public static class YamlTimelineParser + { + // 来自 Assets/Scripts/SceneManagement/TimelineKit/DirectorHandler.cs.meta + public const string DirectorHandlerScriptGuid = "07d657d6b80509b4eb04f59afaa9aa2d"; + + 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 = TimelineNameCache.GetAssetFullPath(assetPath); + if (string.IsNullOrEmpty(fullPath) || !File.Exists(fullPath)) + return result; + + string[] lines; + try + { + lines = File.ReadAllLines(fullPath); + } + catch (Exception e) + { + Debug.LogError($"[YamlTimelineParser] Failed to read '{fullPath}': {e}"); + return result; + } + + if (lines.Length == 0 || !lines[0].StartsWith("%YAML", StringComparison.Ordinal)) + { + // 非 text 序列化,无法解析 + 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)) + continue; + + var monoInfo = ExtractDirectorHandlerInfo( + 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 TimelineNameCacheEntry 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; + int timelineNameLine = -1; + + for (var i = obj.startLine; i <= obj.endLine; i++) + { + var rawLine = lines[i]; + var line = rawLine.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); + timelineNameLine = i + 1; // 转为 1-based + } + } + } + + if (string.IsNullOrEmpty(timelineName)) + { + // 没有设置 timelineName 的组件不计入 + return null; + } + + var isInScene = assetPath.EndsWith(".unity", StringComparison.OrdinalIgnoreCase); + var goPath = BuildGameObjectPath(gameObjectFileId, gameObjectNames, transforms, transformByGameObject); + + return new TimelineNameCacheEntry + { + guid = guid, + assetPath = assetPath, + fileHash = fileHash, + lastModified = lastModifiedUtc, + timelineName = timelineName, + gameObjectPath = goPath, + isInScene = isInScene, + lineNumber = timelineNameLine > 0 ? timelineNameLine : 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); + } + + /// + /// Unity YAML 中的字符串可能将非 ASCII 字符存储为 \uXXXX 转义,需要解码为实际 Unicode 字符。 + /// + 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/TimelineNameCollector/YamlTimelineParser.cs.meta b/Assets/Editor/TimelineNameCollector/YamlTimelineParser.cs.meta new file mode 100644 index 000000000..fbb7a24f4 --- /dev/null +++ b/Assets/Editor/TimelineNameCollector/YamlTimelineParser.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 19f28acc911bf7d4e86035daebc33190 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: