#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