feat(editor): 添加 Timeline 名称收集器编辑器工具

Made-with: Cursor
This commit is contained in:
2026-03-05 19:11:02 +08:00
parent cbe1655105
commit cb49557aed
10 changed files with 1249 additions and 0 deletions
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3961ba56a1773164f8605d3a1413e07f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
}
]
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 65cb908453570044abbc145dac80f6de
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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<TimelineNameCacheEntry> entries = new List<TimelineNameCacheEntry>();
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<TimelineNameCacheEntry>()
};
}
var json = File.ReadAllText(CacheFileFullPath, Encoding.UTF8);
if (string.IsNullOrEmpty(json))
{
return new TimelineNameCache
{
version = CurrentVersion,
lastFullScanTime = null,
entries = new List<TimelineNameCacheEntry>()
};
}
var cache = JsonConvert.DeserializeObject<TimelineNameCache>(json);
if (cache == null)
{
cache = new TimelineNameCache();
}
if (cache.version != CurrentVersion)
{
cache.version = CurrentVersion;
cache.lastFullScanTime = null;
cache.entries = cache.entries ?? new List<TimelineNameCacheEntry>();
}
return cache;
}
catch (Exception e)
{
Debug.LogError($"[TimelineNameCache] Failed to load cache: {e}");
return new TimelineNameCache
{
version = CurrentVersion,
lastFullScanTime = null,
entries = new List<TimelineNameCacheEntry>()
};
}
}
public void Save()
{
try
{
if (!Directory.Exists(CacheFolderFullPath))
{
Directory.CreateDirectory(CacheFolderFullPath);
}
entries ??= new List<TimelineNameCacheEntry>();
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;
});
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 58e8fb78cff00f44482eff9d6a1b7a64
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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<TimelineNameCacheEntry> _sortedEntries = new List<TimelineNameCacheEntry>();
private readonly Dictionary<string, List<TimelineNameCacheEntry>> _entriesByName =
new Dictionary<string, List<TimelineNameCacheEntry>>(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _conflictNames =
new HashSet<string>(StringComparer.OrdinalIgnoreCase);
private TimelineNameCacheEntry _selectedEntry;
[MenuItem("Tools/Timeline Name Collector")]
public static void ShowWindow()
{
var window = GetWindow<TimelineNameCollectorWindow>("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<TimelineNameCacheEntry>();
}
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<TimelineNameCacheEntry>();
_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<string>(sceneGuids);
foreach (var g in prefabGuids)
allGuids.Add(g);
var guidList = new List<string>(allGuids);
guidList.Sort();
// 现有文件哈希,按 assetPath 去重
var existingHashes = new Dictionary<string, string>(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;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6a43798aefb6a4142a7abe8d3dc1649a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,375 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using UnityEditor;
using UnityEngine;
namespace AibisDream.EditorTools
{
/// <summary>
/// 解析 Unity YAML 场景 / 预制体中的 DirectorHandler 组件,提取 timelineName 和 GameObject 路径。
/// 解析方式:按 "--- !u!<classId> &<fileId>" 分段,构建 GameObject / Transform / MonoBehaviour 的索引。
/// </summary>
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<TimelineNameCacheEntry> ParseAsset(
string assetPath,
string guid,
string fileHash,
string lastModifiedUtc)
{
var result = new List<TimelineNameCacheEntry>();
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<long, string>();
var transforms = new Dictionary<long, TransformInfo>();
var transformByGameObject = new Dictionary<long, long>();
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<YamlObject> BuildObjects(string[] lines)
{
var objects = new List<YamlObject>();
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<long, string> gameObjectNames,
Dictionary<long, TransformInfo> transforms,
Dictionary<long, long> 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<long, string> gameObjectNames,
Dictionary<long, TransformInfo> transforms,
Dictionary<long, long> 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<string>();
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);
}
/// <summary>
/// Unity YAML 中的字符串可能将非 ASCII 字符存储为 \uXXXX 转义,需要解码为实际 Unicode 字符。
/// </summary>
private static string UnescapeUnityYamlString(string value)
{
if (string.IsNullOrEmpty(value)) return value;
try
{
return Regex.Unescape(value);
}
catch
{
return value;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 19f28acc911bf7d4e86035daebc33190
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: