#if UNITY_EDITOR using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Text; using UnityEditor; #if UNITY_2021_2_OR_NEWER using PrefabStageUtility = UnityEditor.SceneManagement.PrefabStageUtility; #else using PrefabStageUtility = UnityEditor.Experimental.SceneManagement.PrefabStageUtility; #endif using UnityEngine; using UnityEngine.Playables; using UnityEngine.Timeline; public static class AutoBindTimelineByFieldNameMenu { private const string MenuPath = "GameObject/Timeline/按字段名自动挂载 Timeline"; private static readonly string[] PreferredFolderKeywords = { "timeline", "playable" }; [MenuItem(MenuPath, false, 49)] private static void AutoBindTimelinesByFieldName() { if (!TryGetTargetBehaviours(Selection.activeGameObject, out var behaviours, out var error)) { Debug.LogError(error); return; } if (!TryGetSearchFolders(Selection.activeGameObject, out var searchFolders, out error)) { Debug.LogError(error, Selection.activeGameObject); return; } var changedCount = 0; var unchangedCount = 0; var missingCount = 0; var duplicateCount = 0; var details = new StringBuilder(); foreach (var behaviour in behaviours) { var serializedObject = new SerializedObject(behaviour); var componentChanged = false; Undo.RecordObject(behaviour, "Auto Bind Timelines By Field Name"); foreach (var field in GetPlayableAssetFields(behaviour.GetType())) { var property = serializedObject.FindProperty(field.Name); if (property == null || property.propertyType != SerializedPropertyType.ObjectReference) { continue; } var searchResult = FindTimeline(field.Name, searchFolders); if (searchResult.Status == TimelineSearchStatus.Missing) { missingCount++; details.AppendLine($"未找到: {GetComponentLabel(behaviour)}.{field.Name}"); continue; } if (searchResult.Status == TimelineSearchStatus.Duplicate) { duplicateCount++; details.AppendLine($"重名冲突: {GetComponentLabel(behaviour)}.{field.Name}"); foreach (var path in searchResult.Paths) { details.AppendLine($" - {path}"); } continue; } if (property.objectReferenceValue == searchResult.Asset) { unchangedCount++; continue; } property.objectReferenceValue = searchResult.Asset; changedCount++; componentChanged = true; details.AppendLine($"已挂载: {GetComponentLabel(behaviour)}.{field.Name} -> {searchResult.Paths[0]}"); } serializedObject.ApplyModifiedProperties(); if (componentChanged) { EditorUtility.SetDirty(behaviour); } } var summary = new StringBuilder(); summary.AppendLine($"Timeline 挂载完成: 修改 {changedCount},未变更 {unchangedCount},未找到 {missingCount},重名冲突 {duplicateCount}。"); summary.AppendLine($"搜索目录: {string.Join(" | ", searchFolders)}"); if (details.Length > 0) { summary.AppendLine(details.ToString().TrimEnd()); } if (missingCount > 0 || duplicateCount > 0) { Debug.LogError(summary.ToString(), Selection.activeGameObject); return; } Debug.Log(summary.ToString(), Selection.activeGameObject); } [MenuItem(MenuPath, true)] private static bool ValidateAutoBindTimelinesByFieldName() { return TryGetTargetBehaviours(Selection.activeGameObject, out _, out _); } private static bool TryGetTargetBehaviours(GameObject selectedObject, out List behaviours, out string error) { behaviours = new List(); error = null; if (selectedObject == null) { error = "未选中任何 GameObject。"; return false; } behaviours = selectedObject .GetComponentsInChildren(true) .Where(behaviour => behaviour != null && GetPlayableAssetFields(behaviour.GetType()).Any()) .ToList(); if (behaviours.Count > 0) { return true; } error = $"选中对象 `{selectedObject.name}` 层级下未找到包含序列化 PlayableAsset 字段的组件。"; return false; } private static bool TryGetSearchFolders(GameObject selectedObject, out List searchFolders, out string error) { searchFolders = new List(); error = null; var prefabPath = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(selectedObject); if (string.IsNullOrEmpty(prefabPath)) { var prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); if (prefabStage != null) { prefabPath = prefabStage.assetPath; } } if (string.IsNullOrEmpty(prefabPath)) { error = "当前选择对象不在 Prefab 实例或 Prefab 编辑模式中,无法确定搜索目录。"; return false; } var prefabFolder = NormalizePath(Path.GetDirectoryName(prefabPath)); if (string.IsNullOrEmpty(prefabFolder)) { error = $"Prefab 路径 `{prefabPath}` 无法转换为有效目录。"; return false; } var fallbackFolders = new List(); var currentFolder = prefabFolder; while (!string.IsNullOrEmpty(currentFolder) && currentFolder.StartsWith("Assets", StringComparison.Ordinal)) { fallbackFolders.Add(currentFolder); var preferredFolders = AssetDatabase .GetSubFolders(currentFolder) .Where(IsPreferredSearchFolder) .Select(NormalizePath) .OrderBy(path => path.Count(ch => ch == '/')) .ThenBy(path => path, StringComparer.Ordinal) .ToList(); foreach (var folder in preferredFolders) { AddUnique(searchFolders, folder); } AddUnique(searchFolders, currentFolder); if (string.Equals(currentFolder, "Assets", StringComparison.Ordinal)) { break; } currentFolder = NormalizePath(Path.GetDirectoryName(currentFolder)); } if (searchFolders.Count == 0) { foreach (var folder in fallbackFolders) { AddUnique(searchFolders, folder); } } if (searchFolders.Count == 0) { error = $"Prefab 路径 `{prefabPath}` 无法生成有效搜索目录。"; return false; } return true; } private static IEnumerable GetPlayableAssetFields(Type type) { return type .GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) .Where(field => typeof(PlayableAsset).IsAssignableFrom(field.FieldType) && IsSerializedField(field)); } private static bool IsSerializedField(FieldInfo field) { if (field.IsDefined(typeof(NonSerializedAttribute), true)) { return false; } return field.IsPublic || field.IsDefined(typeof(SerializeField), true); } private static TimelineSearchResult FindTimeline(string fieldName, IReadOnlyList searchFolders) { foreach (var folder in searchFolders) { var paths = AssetDatabase .FindAssets($"{fieldName} t:{nameof(TimelineAsset)}", new[] { folder }) .Select(AssetDatabase.GUIDToAssetPath) .Where(path => string.Equals(Path.GetFileNameWithoutExtension(path), fieldName, StringComparison.Ordinal)) .Distinct() .ToList(); if (paths.Count == 0) { continue; } if (paths.Count > 1) { return TimelineSearchResult.CreateDuplicate(paths); } var asset = AssetDatabase.LoadAssetAtPath(paths[0]); if (asset == null) { return TimelineSearchResult.CreateMissing(); } return TimelineSearchResult.CreateFound(asset, paths[0]); } return TimelineSearchResult.CreateMissing(); } private static bool IsPreferredSearchFolder(string folderPath) { var folderName = Path.GetFileName(folderPath); if (string.IsNullOrEmpty(folderName)) { return false; } return PreferredFolderKeywords.Any(keyword => folderName.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0); } private static void AddUnique(List folders, string folder) { if (!string.IsNullOrEmpty(folder) && !folders.Contains(folder)) { folders.Add(folder); } } private static string NormalizePath(string path) { return string.IsNullOrEmpty(path) ? string.Empty : path.Replace('\\', '/'); } private static string GetComponentLabel(MonoBehaviour behaviour) { return $"{behaviour.gameObject.name}/{behaviour.GetType().Name}"; } private enum TimelineSearchStatus { Missing, Found, Duplicate, } private readonly struct TimelineSearchResult { public readonly TimelineSearchStatus Status; public readonly TimelineAsset Asset; public readonly IReadOnlyList Paths; private TimelineSearchResult(TimelineSearchStatus status, TimelineAsset asset, IReadOnlyList paths) { Status = status; Asset = asset; Paths = paths; } public static TimelineSearchResult CreateMissing() { return new TimelineSearchResult(TimelineSearchStatus.Missing, null, Array.Empty()); } public static TimelineSearchResult CreateFound(TimelineAsset asset, string path) { return new TimelineSearchResult(TimelineSearchStatus.Found, asset, new[] { path }); } public static TimelineSearchResult CreateDuplicate(IReadOnlyList paths) { return new TimelineSearchResult(TimelineSearchStatus.Duplicate, null, paths); } } } #endif