946 lines
38 KiB
C#
946 lines
38 KiB
C#
#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 迁移绑定到 Cell;2) 从 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("源 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<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 迁移", "请指定源 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<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
|