Files
ft/Client/Assets/Scripts/EventBossFight/Editor/EventBossFightBossTimelineBindingsMigrationWindow.cs
2026-06-29 21:18:33 +08:00

946 lines
38 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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
{
/// <summary>
/// 1) 从面板 boss 根 Director 迁移绑定到 Cell2) 从 Prefab 同级目录加载 bossfight_bossNN_* 并写入 Cell + 补全轨绑定。
/// </summary>
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;
/// <summary>「从目录绑定」专用;留空则用当前 Hierarchy 选中。</summary>
[SerializeField]
private EventBossFightTimelineBossCellVideo _cellForFolderBind;
[MenuItem("Tools/EventBossFight/迁移 Boss Timeline 绑定到 Cell Director")]
private static void Open()
{
var w = GetWindow<EventBossFightBossTimelineBindingsMigrationWindow>("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<EventBossFightTimelineBossCellVideo>() ??
go.GetComponentInParent<EventBossFightTimelineBossCellVideo>();
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<EventBossFightTimelineBossCellVideo>() ||
Selection.activeGameObject.GetComponentInParent<EventBossFightTimelineBossCellVideo>());
private void OnGUI()
{
EditorGUILayout.LabelField("一、从面板 boss 根 Director 迁移", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"面板上多个 Timeline 资源时勾选「批量」。会迁移Cell 子树、boss 根 Animator、boss 下非其它 Video 槽的节点。",
MessageType.Info);
_sourceBossRootDirector =
(PlayableDirector)EditorGUILayout.ObjectField("源 Directorboss 根)", _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<EventBossFightTimelineBossCellVideo>() ??
go.GetComponentInParent<EventBossFightTimelineBossCellVideo>();
return cell;
}
/// <summary>
/// 从 Prefab 所在目录(及 timeline 子目录)加载 bossfight_bossNN_*,赋值 Cell并对 Cell Director 补绑定。
/// </summary>
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<TimelineAsset> CollectTimelinesToProcess(EventBossFightTimelineBossCellVideo cell)
{
var seen = new HashSet<TimelineAsset>();
var list = new List<TimelineAsset>();
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<string> 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<TimelineAsset>(path))
{
cell.showTimeline = AssetDatabase.LoadAssetAtPath<TimelineAsset>(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<string> 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<TimelineAsset>();
timelineSo.name = displayBaseName;
AssetDatabase.CreateAsset(timelineSo, assetPath);
timeline = timelineSo;
var track = timeline.CreateTrack<LeviathanVideoTrack>(null, "Leviathan Video");
var timelineClip = track.CreateClip<LeviathanVideoPlayableAsset>();
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<Animator>() ?? bossMount.GetComponentInChildren<Animator>(true)
: null;
if (!anim)
anim = cellRoot.GetComponentInChildren<Animator>(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<Transform>(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<LeviathanVideoDecoderBase>(true) : null;
}
private static Transform FindLayerTransform(Transform root, string layerName)
{
if (!root)
return null;
foreach (var t in root.GetComponentsInChildren<Transform>(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<string> BuildTimelineSearchDirectories(string prefabDirectory)
{
var list = new List<string>();
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<string> 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<TimelineAsset>(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<string> 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<TextAsset>(path);
if (asset)
return asset;
}
return null;
}
private void Migrate()
{
if (!_sourceBossRootDirector)
{
EditorUtility.DisplayDialog("Boss Timeline 迁移", "请指定源 Directorboss 根)。", "确定");
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<TimelineAsset> CollectTimelinesToMigrate(EventBossFightTimelineBossCellVideo cell,
PlayableDirector sourceDirector, bool batchFromCellFields)
{
var seen = new HashSet<TimelineAsset>();
var list = new List<TimelineAsset>();
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<EventBossFightTimelineBossCellVideo>())
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<EventBossFightTimelineBossCellVideo>() ??
slot.GetComponentInChildren<EventBossFightTimelineBossCellVideo>(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<PlayableDirector>();
if (!added)
added = Undo.AddComponent<PlayableDirector>(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