#if UNITY_EDITOR using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using FT.Timeline; using UnityEditor; #if UNITY_2021_2_OR_NEWER using PrefabStageUtility = UnityEditor.SceneManagement.PrefabStageUtility; using UnityEditor.SceneManagement; #else using PrefabStageUtility = UnityEditor.Experimental.SceneManagement.PrefabStageUtility; #endif using UnityEngine; using UnityEngine.Playables; using UnityEngine.Timeline; namespace EventBossFight.Editor { /// /// 1) 从面板 boss 根 Director 迁移绑定到 Cell;2) 从 Prefab 同级目录加载 bossfight_bossNN_* 并写入 Cell + 补全轨绑定。 /// public sealed class EventBossFightBossTimelineBindingsMigrationWindow : EditorWindow { private const string BytesBossPrefix = "atlantis_boss"; [SerializeField] private PlayableDirector _sourceBossRootDirector; [SerializeField] private string _bossSlotObjectName = ""; [SerializeField] private EventBossFightTimelineBossCellVideo _targetCell; [SerializeField] private bool _migrateAllTimelinesOnCell = true; /// 「从目录绑定」专用;留空则用当前 Hierarchy 选中。 [SerializeField] private EventBossFightTimelineBossCellVideo _cellForFolderBind; [MenuItem("Tools/EventBossFight/迁移 Boss Timeline 绑定到 Cell Director")] private static void Open() { var w = GetWindow("Boss Timeline 迁移"); w.minSize = new Vector2(460, 420); } [MenuItem("Tools/EventBossFight/从 Prefab 目录绑定 Cell Timeline(补全轨)", false, 11)] private static void QuickBindFromSelection() { var go = Selection.activeGameObject; if (!go) { EditorUtility.DisplayDialog("绑定 Timeline", "请先在 Hierarchy 或 Prefab 模式中选中 atlantis_boss_N 根(挂有 EventBossFightTimelineBossCellVideo)。", "确定"); return; } var cell = go.GetComponent() ?? go.GetComponentInParent(); if (!cell) { EditorUtility.DisplayDialog("绑定 Timeline", "选中物体上未找到 EventBossFightTimelineBossCellVideo。", "确定"); return; } BindFromPrefabSiblingFolderAndRepairTracks(cell, quietUi: false); } [MenuItem("Tools/EventBossFight/从 Prefab 目录绑定 Cell Timeline(补全轨)", true)] private static bool QuickBindFromSelectionValidate() => Selection.activeGameObject && (Selection.activeGameObject.GetComponent() || Selection.activeGameObject.GetComponentInParent()); private void OnGUI() { EditorGUILayout.LabelField("一、从面板 boss 根 Director 迁移", EditorStyles.boldLabel); EditorGUILayout.HelpBox( "面板上多个 Timeline 资源时勾选「批量」。会迁移:Cell 子树、boss 根 Animator、boss 下非其它 Video 槽的节点。", MessageType.Info); _sourceBossRootDirector = (PlayableDirector)EditorGUILayout.ObjectField("源 Director(boss 根)", _sourceBossRootDirector, typeof(PlayableDirector), true); _bossSlotObjectName = EditorGUILayout.TextField("Boss 槽位物体名(可选)", _bossSlotObjectName); _targetCell = (EventBossFightTimelineBossCellVideo)EditorGUILayout.ObjectField("目标 Cell(可选)", _targetCell, typeof(EventBossFightTimelineBossCellVideo), true); _migrateAllTimelinesOnCell = EditorGUILayout.ToggleLeft( "批量:迁移 Cell 引用的全部 Timeline", _migrateAllTimelinesOnCell); var canMigrate = _sourceBossRootDirector && (_targetCell || !string.IsNullOrWhiteSpace(_bossSlotObjectName)); using (new EditorGUI.DisabledScope(!canMigrate)) { if (GUILayout.Button("复制绑定到 Cell Director(从面板迁移)")) Migrate(); } EditorGUILayout.Space(12); EditorGUILayout.LabelField("二、从 Prefab 同级目录绑定(推荐分包)", EditorStyles.boldLabel); EditorGUILayout.HelpBox( "根据物体名解析 N(如 atlantis_boss_5 → 5),在 Prefab 同级及 timeline 子文件夹查找 bossfight_boss05_* .playable,\n" + "写入 Cell 各字段并挂到 Cell 的 PlayableDirector;轨无绑定则按 idle/attack/hit… 自动绑到子节点。\n" + "若无 show 资源且存在 atlantis_boss05_show.bytes,会在同目录创建 bossfight_boss05_show.playable。", MessageType.Info); _cellForFolderBind = (EventBossFightTimelineBossCellVideo)EditorGUILayout.ObjectField("目标 Cell(可空=用选中)", _cellForFolderBind, typeof(EventBossFightTimelineBossCellVideo), true); using (new EditorGUI.DisabledScope(!GetCellForFolderWorkflow(out _))) { if (GUILayout.Button("从目录绑定全部 Timeline + 补全轨绑定")) { if (GetCellForFolderWorkflow(out var cell)) BindFromPrefabSiblingFolderAndRepairTracks(cell, quietUi: false); } } } private bool GetCellForFolderWorkflow(out EventBossFightTimelineBossCellVideo cell) { cell = _cellForFolderBind; if (cell) return true; var go = Selection.activeGameObject; if (!go) return false; cell = go.GetComponent() ?? go.GetComponentInParent(); return cell; } /// /// 从 Prefab 所在目录(及 timeline 子目录)加载 bossfight_bossNN_*,赋值 Cell,并对 Cell Director 补绑定。 /// public static void BindFromPrefabSiblingFolderAndRepairTracks(EventBossFightTimelineBossCellVideo cell, bool quietUi) { if (!cell) { if (!quietUi) EditorUtility.DisplayDialog("绑定 Timeline", "Cell 为空。", "确定"); return; } if (!TryParseBossIndexFromAtlantisName(cell.gameObject.name, out var bossIndex, out var parseErr)) { if (!quietUi) EditorUtility.DisplayDialog("绑定 Timeline", parseErr, "确定"); return; } if (!TryGetPrefabAssetDirectory(cell.gameObject, out var prefabDir, out var pathErr)) { if (!quietUi) EditorUtility.DisplayDialog("绑定 Timeline", pathErr, "确定"); return; } var searchDirs = BuildTimelineSearchDirectories(prefabDir); Undo.RecordObject(cell, "Boss Cell Timeline 目录绑定"); var director = EnsureCellDirector(cell); if (!director) { if (!quietUi) EditorUtility.DisplayDialog("绑定 Timeline", "无法创建 PlayableDirector。", "确定"); return; } Undo.RecordObject(director, "Boss Cell Timeline 目录绑定"); var timelineFolder = PickTimelineOutputFolder(searchDirs, bossIndex); if (string.IsNullOrEmpty(timelineFolder) && searchDirs.Count > 0) timelineFolder = searchDirs[0]; EnsureShowTimelineAsset(cell, bossIndex, searchDirs, timelineFolder, quietUi); var semantics = new[] { ("showTimeline", "show", "show"), ("idleTimeline", "idle", "idle"), ("deathTimeline", "death", "death"), ("attackTimeline", "attack", "attack"), ("attacked1Timeline", "attacked01", "attacked01"), ("attacked2Timeline", "attacked02", "attacked02"), }; var so = new SerializedObject(cell); var assigned = 0; foreach (var (fieldKey, fileSuffix, semantic) in semantics) { var ta = LoadBossTimelineAssetFromDirs(bossIndex, fileSuffix, searchDirs); if (!ta) continue; var prop = so.FindProperty(fieldKey); if (prop != null && prop.propertyType == SerializedPropertyType.ObjectReference) { prop.objectReferenceValue = ta; assigned++; } } so.ApplyModifiedProperties(); EditorUtility.SetDirty(cell); var bossMount = FindBossMountTransform(cell.transform); var timelines = CollectTimelinesToProcess(cell); var boundTracks = 0; var filledMissing = 0; foreach (var ta in timelines) { if (!ta) continue; director.playableAsset = ta; var semantic = GuessSemanticFromTimelineName(ta.name, bossIndex); foreach (var track in ta.GetOutputTracks()) { var existing = director.GetGenericBinding(track); if (existing != null) { boundTracks++; continue; } if (TryAutoBindTrack(director, track, cell.transform, bossMount, semantic, ta.name)) filledMissing++; else Debug.LogWarning($"[EventBossFight] 未能自动绑定轨「{track.name}」({ta.name}),请在 Timeline 窗口手动指定。", ta); } } director.playableAsset = cell.idleTimeline ? cell.idleTimeline : timelines.FirstOrDefault(t => t); EditorUtility.SetDirty(director); if (PrefabUtility.IsPartOfPrefabInstance(cell)) PrefabUtility.RecordPrefabInstancePropertyModifications(cell); if (PrefabUtility.IsPartOfPrefabInstance(director)) PrefabUtility.RecordPrefabInstancePropertyModifications(director); AssetDatabase.SaveAssets(); var msg = $"Boss 序号:{bossIndex}\n目录:{prefabDir}\n写入字段引用:{assigned} 个资源\n已有绑定轨:{boundTracks}\n本次补绑定:{filledMissing}"; Debug.Log($"[EventBossFight] 目录绑定完成。\n{msg}", cell); if (!quietUi) EditorUtility.DisplayDialog("绑定 Timeline", msg, "确定"); } private static List CollectTimelinesToProcess(EventBossFightTimelineBossCellVideo cell) { var seen = new HashSet(); var list = new List(); void Add(PlayableAsset a) { if (a is TimelineAsset ta && seen.Add(ta)) list.Add(ta); } Add(cell.showTimeline); Add(cell.idleTimeline); Add(cell.deathTimeline); Add(cell.attackTimeline); Add(cell.attacked1Timeline); Add(cell.attacked2Timeline); return list; } private static void EnsureShowTimelineAsset(EventBossFightTimelineBossCellVideo cell, int bossIndex, IReadOnlyList searchDirs, string outputFolder, bool quietUi) { if (cell.showTimeline) return; var existing = LoadBossTimelineAssetFromDirs(bossIndex, "show", searchDirs); if (existing) { cell.showTimeline = existing; EditorUtility.SetDirty(cell); return; } var textAsset = FindTextAssetForBossBytes(bossIndex, "show", null); if (!textAsset) { if (!quietUi) Debug.Log($"[EventBossFight] 未找到 atlantis_boss{bossIndex:D2}_show.bytes,跳过创建 show Timeline。", cell); return; } var baseName = $"bossfight_boss{bossIndex:D2}_show"; var folder = !string.IsNullOrEmpty(outputFolder) ? outputFolder : (searchDirs.Count > 0 ? searchDirs[0] : null); if (string.IsNullOrEmpty(folder)) { Debug.LogWarning("[EventBossFight] 无法确定 show.playable 输出目录。", cell); return; } var path = $"{folder.TrimEnd('/')}/{baseName}.playable".Replace("\\", "/"); if (AssetDatabase.LoadAssetAtPath(path)) { cell.showTimeline = AssetDatabase.LoadAssetAtPath(path); EditorUtility.SetDirty(cell); return; } if (!TryCreateLeviathanTimelineAsset(path, baseName, textAsset, out var timeline)) { Debug.LogError($"[EventBossFight] 创建 show Timeline 失败:{path}", cell); return; } AssetDatabase.SaveAssets(); cell.showTimeline = timeline; EditorUtility.SetDirty(cell); Debug.Log($"[EventBossFight] 已创建 show Timeline:{path}", timeline); } private static string PickTimelineOutputFolder(IReadOnlyList searchDirs, int bossIndex) { foreach (var d in searchDirs) { if (LoadBossTimelineAssetFromDirs(bossIndex, "idle", new[] { d })) return d.TrimEnd('/'); } foreach (var d in searchDirs) { if (d.EndsWith("/timeline", System.StringComparison.OrdinalIgnoreCase) || d.EndsWith("\\timeline", System.StringComparison.OrdinalIgnoreCase)) return d.TrimEnd('/').Replace("\\", "/"); } return searchDirs.Count > 0 ? searchDirs[0].TrimEnd('/').Replace("\\", "/") : null; } private static bool TryCreateLeviathanTimelineAsset(string assetPath, string displayBaseName, TextAsset textAsset, out TimelineAsset timeline) { timeline = null; var timelineSo = ScriptableObject.CreateInstance(); timelineSo.name = displayBaseName; AssetDatabase.CreateAsset(timelineSo, assetPath); timeline = timelineSo; var track = timeline.CreateTrack(null, "Leviathan Video"); var timelineClip = track.CreateClip(); timelineClip.start = 0; timelineClip.displayName = displayBaseName; var clipAsset = timelineClip.asset as LeviathanVideoPlayableAsset; if (!clipAsset) { Debug.LogError($"创建 Clip 失败:{assetPath}"); return false; } clipAsset.videoOverride = textAsset; if (LeviathanVideoDecoderBase.TryEditorProbeVideoMetaFromBytes(textAsset, out var dur, out _, out _) && dur > 0.0001) clipAsset.clipDurationSeconds = dur; else clipAsset.clipDurationSeconds = 5.0; timelineClip.duration = clipAsset.duration; EditorUtility.SetDirty(clipAsset); EditorUtility.SetDirty(track); EditorUtility.SetDirty(timeline); return true; } private static bool TryAutoBindTrack(PlayableDirector director, TrackAsset track, Transform cellRoot, Transform bossMount, string timelineSemantic, string timelineAssetName) { switch (track) { case LeviathanVideoTrack: { var dec = ResolveDecoderForSemantic(cellRoot, timelineSemantic); if (!dec) return false; director.SetGenericBinding(track, dec); return true; } case AnimationTrack: { var anim = bossMount ? bossMount.GetComponent() ?? bossMount.GetComponentInChildren(true) : null; if (!anim) anim = cellRoot.GetComponentInChildren(true); if (!anim) return false; director.SetGenericBinding(track, anim); return true; } case ActivationTrack: { var go = ResolveActivationTarget(bossMount, cellRoot, timelineSemantic, timelineAssetName); if (!go) return false; director.SetGenericBinding(track, go); return true; } default: return false; } } private static GameObject ResolveActivationTarget(Transform bossMount, Transform cellRoot, string timelineSemantic, string timelineAssetName) { if (!bossMount) return null; var hint = (timelineSemantic + " " + timelineAssetName).ToLowerInvariant(); GameObject best = null; foreach (var t in bossMount.GetComponentsInChildren(true)) { if (IsUnderOtherBossVideoSlot(t, bossMount, cellRoot)) continue; var n = t.name.ToLowerInvariant(); if (hint.Contains("death") || timelineAssetName.ToLowerInvariant().Contains("death")) { if (n.Contains("boss_death") || n.Contains("fx_eventbossfight_boss_death")) return t.gameObject; } if (best == null && n.StartsWith("fx_eventbossfight")) best = t.gameObject; } return best; } private static LeviathanVideoDecoderBase ResolveDecoderForSemantic(Transform cellRoot, string semantic) { semantic ??= "idle"; switch (semantic) { case "show": return FindDecoderUnderLayer(cellRoot, "show"); case "idle": return FindDecoderUnderLayer(cellRoot, "idle"); case "death": return FindDecoderUnderLayer(cellRoot, "death") ?? FindDecoderUnderLayer(cellRoot, "die"); case "attack": return FindDecoderUnderLayer(cellRoot, "attack"); case "attacked01": return FindDecoderUnderLayer(cellRoot, "hit"); case "attacked02": return FindDecoderUnderLayer(cellRoot, "hit_02"); default: return FindDecoderUnderLayer(cellRoot, "idle"); } } private static LeviathanVideoDecoderBase FindDecoderUnderLayer(Transform root, string layerName) { var layerTf = FindLayerTransform(root, layerName); return layerTf ? layerTf.GetComponentInChildren(true) : null; } private static Transform FindLayerTransform(Transform root, string layerName) { if (!root) return null; foreach (var t in root.GetComponentsInChildren(true)) { if (t == root) continue; if (t.name == layerName) return t; } return null; } private static string GuessSemanticFromTimelineName(string timelineAssetName, int bossIndex) { var prefixes = new[] { $"bossfight_boss{bossIndex:D2}_", $"bossfight_boss{bossIndex}_", }; foreach (var p in prefixes) { if (timelineAssetName.StartsWith(p, System.StringComparison.OrdinalIgnoreCase)) return timelineAssetName.Substring(p.Length).ToLowerInvariant(); } return "idle"; } private static Transform FindBossMountTransform(Transform cellRoot) { if (!cellRoot) return null; var t = cellRoot.parent; while (t) { if (t.name == "boss") return t; t = t.parent; } return cellRoot.parent; } private static List BuildTimelineSearchDirectories(string prefabDirectory) { var list = new List(); if (string.IsNullOrEmpty(prefabDirectory)) return list; var d = prefabDirectory.TrimEnd('/').Replace("\\", "/"); list.Add(d); var sub = $"{d}/timeline"; if (AssetDatabase.IsValidFolder(sub)) list.Add(sub); return list; } private static TimelineAsset LoadBossTimelineAssetFromDirs(int bossIndex, string suffix, IReadOnlyList dirs) { var names = new[] { $"bossfight_boss{bossIndex:D2}_{suffix}", $"bossfight_boss{bossIndex}_{suffix}", }; foreach (var dir in dirs) { foreach (var n in names) { var p = $"{dir.TrimEnd('/')}/{n}.playable".Replace("\\", "/"); var ta = AssetDatabase.LoadAssetAtPath(p); if (ta) return ta; } } return null; } private static readonly Regex AtlantisBossIndexRegex = new Regex(@"atlantis_boss_(\d+)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); private static bool TryParseBossIndexFromAtlantisName(string objectName, out int bossIndex, out string error) { bossIndex = 0; error = null; if (string.IsNullOrEmpty(objectName)) { error = "物体名为空。"; return false; } var m = AtlantisBossIndexRegex.Match(objectName); if (m.Success && int.TryParse(m.Groups[1].Value, out bossIndex) && bossIndex > 0) return true; error = $"无法从名称「{objectName}」解析 Boss 序号,请使用如 atlantis_boss_5 命名。"; return false; } private static bool TryGetPrefabAssetDirectory(GameObject go, out string directory, out string error) { directory = null; error = null; if (!go) { error = "GameObject 为空。"; return false; } var prefabPath = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(go); if (string.IsNullOrEmpty(prefabPath)) { var outer = PrefabUtility.GetOutermostPrefabInstanceRoot(go); if (outer) prefabPath = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(outer); } if (string.IsNullOrEmpty(prefabPath)) { var stage = PrefabStageUtility.GetCurrentPrefabStage(); if (stage != null && stage.prefabContentsRoot && (go.transform == stage.prefabContentsRoot.transform || go.transform.IsChildOf(stage.prefabContentsRoot.transform))) prefabPath = stage.assetPath; } if (string.IsNullOrEmpty(prefabPath) && PrefabUtility.IsPartOfPrefabAsset(go)) prefabPath = AssetDatabase.GetAssetPath(go); if (string.IsNullOrEmpty(prefabPath)) { error = "无法解析 Prefab 资源路径。请将物体存为 Prefab,或在 Prefab 编辑模式下打开后再执行;场景实例需为 Prefab 实例。"; return false; } directory = Path.GetDirectoryName(prefabPath)?.Replace("\\", "/"); if (string.IsNullOrEmpty(directory) || !directory.StartsWith("Assets/", System.StringComparison.OrdinalIgnoreCase)) { error = $"Prefab 目录无效:{directory}"; return false; } return true; } private static TextAsset FindTextAssetForBossBytes(int bossIndex, string primarySuffix, string[] fallbackSuffixes) { foreach (var baseName in BuildAtlantisBytesBaseNames(bossIndex, primarySuffix)) { var ta = FindTextAssetByExactFileName(baseName); if (ta) return ta; } if (fallbackSuffixes == null) return null; foreach (var suf in fallbackSuffixes) { if (suf == primarySuffix) continue; foreach (var baseName in BuildAtlantisBytesBaseNames(bossIndex, suf)) { var ta = FindTextAssetByExactFileName(baseName); if (ta) return ta; } } return null; } private static IEnumerable BuildAtlantisBytesBaseNames(int bossIndex, string suffix) { var d2 = $"{BytesBossPrefix}{bossIndex:D2}_{suffix}"; var plain = $"{BytesBossPrefix}{bossIndex}_{suffix}"; if (d2 == plain) yield return d2; else { yield return d2; yield return plain; } } private static TextAsset FindTextAssetByExactFileName(string fileNameWithoutExtension) { var guids = AssetDatabase.FindAssets($"{fileNameWithoutExtension} t:TextAsset"); foreach (var guid in guids) { var path = AssetDatabase.GUIDToAssetPath(guid); if (Path.GetFileNameWithoutExtension(path) != fileNameWithoutExtension) continue; var asset = AssetDatabase.LoadAssetAtPath(path); if (asset) return asset; } return null; } private void Migrate() { if (!_sourceBossRootDirector) { EditorUtility.DisplayDialog("Boss Timeline 迁移", "请指定源 Director(boss 根)。", "确定"); return; } var resolvedCell = ResolveTargetCell(_sourceBossRootDirector, _targetCell, _bossSlotObjectName); if (!resolvedCell) { EditorUtility.DisplayDialog("Boss Timeline 迁移", "请指定「目标 Cell」,或在 Boss 槽位物体名中填写与 Hierarchy 一致的子物体名(如 atlantis_boss_1),且该节点(或子级)上须有 EventBossFightTimelineBossCellVideo。", "确定"); return; } var timelines = CollectTimelinesToMigrate(resolvedCell, _sourceBossRootDirector, _migrateAllTimelinesOnCell); if (timelines.Count == 0) { EditorUtility.DisplayDialog("Boss Timeline 迁移", _migrateAllTimelinesOnCell ? "未找到任何 TimelineAsset:请先在 Cell 上配置各 Timeline;或取消勾选「批量」并给源 Director 指定 playableAsset。" : "源 Director 未赋值 playableAsset,或不是 TimelineAsset。", "确定"); return; } var targetDir = EnsureCellDirector(resolvedCell); if (!targetDir) { EditorUtility.DisplayDialog("Boss Timeline 迁移", "无法获取或创建 Cell 上的 PlayableDirector。", "确定"); return; } Undo.RecordObject(targetDir, "Migrate boss timeline bindings"); Undo.RecordObject(resolvedCell, "Migrate boss timeline bindings"); var cellRoot = resolvedCell.transform; var bossMount = _sourceBossRootDirector.transform; var migrated = 0; var skippedNoBinding = 0; var skippedOtherBossSlot = 0; var skippedNotUnderCell = 0; foreach (var ta in timelines) { if (!ta) continue; targetDir.playableAsset = ta; var subMigrated = 0; foreach (var track in ta.GetOutputTracks()) { if (!MigrateOneTrack(_sourceBossRootDirector, targetDir, ta, track, cellRoot, bossMount, resolvedCell, ref skippedNoBinding, ref skippedOtherBossSlot, ref skippedNotUnderCell)) continue; subMigrated++; migrated++; } Debug.Log($"[EventBossFight] Timeline「{ta.name}」:本槽迁移 {subMigrated} 条输出轨。", ta); } targetDir.playableAsset = resolvedCell.idleTimeline ? resolvedCell.idleTimeline : timelines[0]; EditorUtility.SetDirty(targetDir); EditorUtility.SetDirty(resolvedCell); if (PrefabUtility.IsPartOfPrefabInstance(resolvedCell)) PrefabUtility.RecordPrefabInstancePropertyModifications(resolvedCell); if (PrefabUtility.IsPartOfPrefabInstance(targetDir)) PrefabUtility.RecordPrefabInstancePropertyModifications(targetDir); AssetDatabase.SaveAssets(); var skipped = skippedNoBinding + skippedOtherBossSlot + skippedNotUnderCell; Debug.Log( $"[EventBossFight] Timeline 迁移完成(槽位「{cellRoot.name}」,共 {timelines.Count} 个资源):累计 {migrated} 条轨。跳过 {skipped} 条。", resolvedCell); EditorUtility.DisplayDialog("Boss Timeline 迁移", $"槽位:{cellRoot.name}\n处理 Timeline 数:{timelines.Count}\n累计已迁移轨:{migrated}\n跳过:{skipped}", "确定"); } private static List CollectTimelinesToMigrate(EventBossFightTimelineBossCellVideo cell, PlayableDirector sourceDirector, bool batchFromCellFields) { var seen = new HashSet(); var list = new List(); void TryAdd(PlayableAsset a) { if (a is not TimelineAsset ta || seen.Contains(ta)) return; seen.Add(ta); list.Add(ta); } if (batchFromCellFields) { TryAdd(cell.showTimeline); TryAdd(cell.idleTimeline); TryAdd(cell.deathTimeline); TryAdd(cell.attackTimeline); TryAdd(cell.attacked1Timeline); TryAdd(cell.attacked2Timeline); } if (sourceDirector.playableAsset is TimelineAsset srcTa) TryAdd(srcTa); return list; } private static bool MigrateOneTrack(PlayableDirector source, PlayableDirector target, TimelineAsset timeline, TrackAsset track, Transform cellRoot, Transform bossMount, EventBossFightTimelineBossCellVideo resolvedCell, ref int skippedNoBinding, ref int skippedOtherBossSlot, ref int skippedNotUnderCell) { var binding = source.GetGenericBinding(track); if (binding == null) { skippedNoBinding++; Debug.LogWarning( $"[EventBossFight] 跳过轨「{track.name}」({timeline.name}):源 Director 上无绑定。", source); return false; } var t = binding is Component c ? c.transform : (binding as GameObject)?.transform; if (!t) { skippedNotUnderCell++; Debug.LogWarning( $"[EventBossFight] 跳过轨「{track.name}」({timeline.name}):绑定类型 {binding.GetType().Name} 无 Transform。", binding as Object); return false; } if (!ShouldMigrateBindingToCellDirector(t, bossMount, cellRoot)) { if (t != bossMount && !t.IsChildOf(bossMount)) { skippedNotUnderCell++; Debug.LogWarning( $"[EventBossFight] 跳过轨「{track.name}」({timeline.name}):绑定「{GetTransformPath(t)}」不在 boss「{GetTransformPath(bossMount)}」下。", t); } else { skippedOtherBossSlot++; Debug.Log( $"[EventBossFight] 跳过轨「{track.name}」({timeline.name}):绑定在其它 Boss 槽「{GetTransformPath(t)}」,本槽为「{cellRoot.name}」。", resolvedCell); } return false; } target.SetGenericBinding(track, binding); return true; } private static bool ShouldMigrateBindingToCellDirector(Transform t, Transform bossMount, Transform cellRoot) { if (!t || !bossMount || !cellRoot) return false; if (t == cellRoot || t.IsChildOf(cellRoot)) return true; if (t == bossMount) return true; if (!t.IsChildOf(bossMount)) return false; var slot = GetBossMountDirectChildOnPath(t, bossMount); if (!slot) return true; if (slot == cellRoot) return true; return !IsOtherBossVideoSlot(slot, cellRoot); } private static Transform GetBossMountDirectChildOnPath(Transform t, Transform bossMount) { if (!t || !bossMount || t == bossMount) return null; var cur = t; while (cur.parent && cur.parent != bossMount) cur = cur.parent; return cur.parent == bossMount ? cur : null; } private static bool IsUnderOtherBossVideoSlot(Transform t, Transform bossMount, Transform cellRoot) { var slot = GetBossMountDirectChildOnPath(t, bossMount); return slot && IsOtherBossVideoSlot(slot, cellRoot); } private static bool IsOtherBossVideoSlot(Transform slot, Transform cellRoot) { if (!slot || slot == cellRoot) return false; if (slot.GetComponent()) return true; var n = slot.name; if (n.StartsWith("atlantis_boss", System.StringComparison.OrdinalIgnoreCase) && !string.Equals(n, cellRoot.name, System.StringComparison.Ordinal)) return true; return false; } private static EventBossFightTimelineBossCellVideo ResolveTargetCell(PlayableDirector sourceBossDirector, EventBossFightTimelineBossCellVideo explicitCell, string bossSlotObjectName) { if (explicitCell) return explicitCell; if (!sourceBossDirector || string.IsNullOrWhiteSpace(bossSlotObjectName)) return null; var mount = sourceBossDirector.transform; var slot = FindChildTransformByName(mount, bossSlotObjectName.Trim()); if (!slot) return null; return slot.GetComponent() ?? slot.GetComponentInChildren(true); } private static Transform FindChildTransformByName(Transform root, string objectName) { if (!root) return null; foreach (Transform c in root) { if (c.name == objectName) return c; } foreach (Transform c in root) { var d = FindChildTransformByName(c, objectName); if (d) return d; } return null; } private static PlayableDirector EnsureCellDirector(EventBossFightTimelineBossCellVideo cell) { var existing = cell.CellPlayableDirector; if (existing) return existing; var added = cell.gameObject.GetComponent(); if (!added) added = Undo.AddComponent(cell.gameObject); var so = new SerializedObject(cell); var prop = so.FindProperty("cellPlayableDirector"); if (prop != null) { prop.objectReferenceValue = added; so.ApplyModifiedProperties(); } return added; } private static string GetTransformPath(Transform tr) { if (!tr) return "(null)"; var s = tr.name; for (var p = tr.parent; p; p = p.parent) s = p.name + "/" + s; return s; } } } #endif