#if UNITY_EDITOR
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using EventBossFight;
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
{
///
/// 在选中带 的 boss 节点上,按子物体 spine0… 批量生成
/// bossfight_boss01_attack.playable 等 Timeline:一条 + 一个 ,
/// 受击片段:Timeline 文件名为 bossfight_bossXX_attacked01.playable,对应 bytes 为 atlantis_bossXX_hit01 / hit02(旧名 attacked01/attacked02 可作 fallback)。
/// Director 绑定:各片段对应 spine* 下同级子物体——idle/death(或 die)/attack/hit/hit_02 上的解码器;
/// Timeline 文件名仍为 attacked01/attacked02,绑定到 hit/hit_02。缺子物体或无解码器时回退 idle。
/// 输出目录默认为选中物体所属 Prefab 所在目录下的 timeline 子文件夹;若不存在则自动创建。无法解析 Prefab 时可选手动指定目录。
///
public static class EventBossFightAtlantisBossTimelineGenerator
{
private static readonly Regex SpineChildRegex = new Regex(@"^spine_?(\d+)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private const string BytesBossPrefix = "atlantis_boss";
///
/// 第 1 项:bossfight_bossXX_{项}.playable;第 2~3 项:仅用于查找 atlantis_bossXX_{第2项}.bytes(受击 Timeline 名仍为 attacked01,bytes 主后缀为 hit01/hit02)。
/// spine 下子物体供 Director 绑定见 。
///
private static readonly (string timelineFileSuffix, string bytesPrimarySuffix, string[] bytesFallback)[] Segments =
{
("idle", "idle", null),
("death", "death", new[] { "die" }),
("attack", "attack", null),
("attacked01", "hit01", new[] { "attacked01" }),
("attacked02", "hit02", new[] { "attacked02" }),
};
private const string MenuPath = "GameObject/EventBossFight/生成 Boss Leviathan Timeline(Leviathan 轨 + atlantis bytes)";
private const string TimelineSubFolderName = "timeline";
[MenuItem(MenuPath, false, 40)]
private static void GenerateFromSelectedBoss()
{
var boss = Selection.activeGameObject;
if (boss == null)
{
Debug.LogError("请先选中场景或 Prefab 中的 boss 物体(挂有 PlayableDirector)。");
return;
}
var director = boss.GetComponent();
if (director == null)
{
Debug.LogError("选中物体上没有 PlayableDirector。", boss);
return;
}
if (!TryGetOrCreateTimelineFolderNearPrefab(boss, out var assetsFolder, out var folderHint))
{
if (!EditorUtility.DisplayDialog(
"生成 Timeline",
$"{folderHint}\n\n是否改为手动选择输出目录(须位于 Assets 下)?",
"手动选择",
"取消"))
return;
var absFolder = EditorUtility.SaveFolderPanel("选择生成 .playable 的目录", Application.dataPath, "");
if (string.IsNullOrEmpty(absFolder))
return;
assetsFolder = AbsolutePathToAssetsPath(absFolder);
if (string.IsNullOrEmpty(assetsFolder))
{
EditorUtility.DisplayDialog("生成 Timeline", "请选择工程 Assets 目录内的文件夹。", "确定");
return;
}
}
Undo.RecordObject(director, "生成 Boss Leviathan Timeline 绑定");
var spineChildren = new List<(Transform spineTf, int slot, int bossResourceIndex)>();
for (var i = 0; i < boss.transform.childCount; i++)
{
var ch = boss.transform.GetChild(i);
var m = SpineChildRegex.Match(ch.name);
if (!m.Success || !int.TryParse(m.Groups[1].Value, out var slot) || slot < 0)
continue;
spineChildren.Add((ch, slot, slot + 1));
}
if (spineChildren.Count == 0)
{
Debug.LogError($"在「{boss.name}」下未找到名为 spine0、spine1… 的子物体。", boss);
return;
}
spineChildren.Sort((a, b) => a.slot.CompareTo(b.slot));
var createdPaths = new List();
var bindingPairs = new List<(TimelineAsset timeline, LeviathanVideoTrack track, LeviathanVideoDecoderBase decoder)>();
foreach (var (spineTf, _, bossResourceIndex) in spineChildren)
{
var idleDecoder = FindDecoderUnderLayer(spineTf, "idle");
var deathDecoder = FindDecoderUnderLayer(spineTf, "death") ?? FindDecoderUnderLayer(spineTf, "die");
var attackDecoder = FindDecoderUnderLayer(spineTf, "attack");
var hitDecoder = FindDecoderUnderLayer(spineTf, "hit");
var hit02Decoder = FindDecoderUnderLayer(spineTf, "hit_02");
if (idleDecoder == null)
Debug.LogWarning($"「{spineTf.name}」下未在 idle 子树找到 LeviathanVideoDecoderBase,仍将生成 Timeline;缺 idle 时其它片段回退绑定也会失败。", spineTf);
foreach (var (fileSuffix, bytesPrimary, bytesFallback) in Segments)
{
var bindDecoder = ResolveBindingDecoderForTimelineSuffix(fileSuffix, idleDecoder, deathDecoder,
attackDecoder, hitDecoder, hit02Decoder);
if (fileSuffix == "death" && deathDecoder == null)
Debug.LogWarning($"「{spineTf.name}」下无子物体 death/die 或未挂解码器,bossfight_boss{bossResourceIndex:D2}_death 将回退绑定 idle。", spineTf);
if (fileSuffix == "attack" && attackDecoder == null)
Debug.LogWarning($"「{spineTf.name}」下无子物体 attack 或未挂解码器,bossfight_boss{bossResourceIndex:D2}_attack 将回退绑定 idle。", spineTf);
if (fileSuffix == "attacked01" && hitDecoder == null)
Debug.LogWarning($"「{spineTf.name}」下无子物体 hit 或未挂解码器,bossfight_boss{bossResourceIndex:D2}_attacked01 将回退绑定 idle。", spineTf);
if (fileSuffix == "attacked02" && hit02Decoder == null)
Debug.LogWarning($"「{spineTf.name}」下无子物体 hit_02 或未挂解码器,bossfight_boss{bossResourceIndex:D2}_attacked02 将回退绑定 idle。", spineTf);
var fileBase = $"bossfight_boss{bossResourceIndex:D2}_{fileSuffix}";
var assetPath = $"{assetsFolder.TrimEnd('/')}/{fileBase}.playable".Replace("\\", "/");
if (AssetExistsAtPath(assetPath))
{
Debug.LogWarning($"已存在,跳过生成:{assetPath}");
var existing = AssetDatabase.LoadAssetAtPath(assetPath);
if (existing != null && bindDecoder != null)
TryCollectBindingPair(existing, bindDecoder, bindingPairs);
continue;
}
var textAsset = FindTextAssetForBossBytes(bossResourceIndex, bytesPrimary, bytesFallback);
if (textAsset == null)
{
Debug.LogWarning($"未找到 TextAsset,跳过:atlantis_boss{bossResourceIndex:D2}_{bytesPrimary}(及 fallback)", spineTf.gameObject);
continue;
}
if (!TryCreateLeviathanTimeline(assetPath, fileBase, textAsset, out var timeline, out var track))
continue;
createdPaths.Add(assetPath);
if (bindDecoder != null)
bindingPairs.Add((timeline, track, bindDecoder));
}
AssignPlayableFieldsToBossCellVideoIfAny(spineTf, assetsFolder, bossResourceIndex);
}
AssetDatabase.SaveAssets();
var preferredBossIndex = spineChildren[0].bossResourceIndex;
ApplyBindingsAndPersistDirector(director, bindingPairs, preferredBossIndex);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log(
$"Boss Timeline 生成完成:新建 {createdPaths.Count} 个资源,目录 {assetsFolder};已对 Director 写入 {bindingPairs.Count} 条轨道绑定(spine 下 idle/death|die/attack/hit/hit_02 按片段对应);当前 Director.playableAsset 已设为 bossfight_boss{preferredBossIndex:D2}_idle(若存在)。",
boss);
}
///
/// 为每个 Timeline 写入 Director 绑定。结束后不要将 playableAsset 置空,否则 Unity 会丢掉已序列化的 Scene Bindings。
/// 默认把 playableAsset 设为最小槽位 spine 对应的 idle,便于在 Timeline 窗口里直接预览。
///
private static void ApplyBindingsAndPersistDirector(
PlayableDirector director,
List<(TimelineAsset timeline, LeviathanVideoTrack track, LeviathanVideoDecoderBase decoder)> bindingPairs,
int preferredBossResourceIndex)
{
if (bindingPairs.Count == 0)
{
EditorUtility.SetDirty(director);
PersistDirectorAndScene(director);
return;
}
var idleName = $"bossfight_boss{preferredBossResourceIndex:D2}_idle";
TimelineAsset defaultPlayable = null;
// SaveAssets 之后用磁盘上的资源再取 Track,避免子资源引用未刷新导致 SetGenericBinding 无效
foreach (var (timeline, _, decoder) in bindingPairs)
{
if (timeline == null || decoder == null)
continue;
var path = AssetDatabase.GetAssetPath(timeline);
var tl = string.IsNullOrEmpty(path)
? timeline
: AssetDatabase.LoadAssetAtPath(path);
if (tl == null)
continue;
var tr = tl.GetOutputTracks().OfType().FirstOrDefault();
if (tr == null)
continue;
director.playableAsset = tl;
director.SetGenericBinding(tr, decoder);
if (tl.name == idleName)
defaultPlayable = tl;
}
if (defaultPlayable == null)
{
var firstPath = AssetDatabase.GetAssetPath(bindingPairs[0].timeline);
defaultPlayable = string.IsNullOrEmpty(firstPath)
? bindingPairs[0].timeline
: AssetDatabase.LoadAssetAtPath(firstPath);
}
director.playableAsset = defaultPlayable;
EditorUtility.SetDirty(director);
PersistDirectorAndScene(director);
}
private static void PersistDirectorAndScene(PlayableDirector director)
{
if (director == null)
return;
if (PrefabUtility.IsPartOfPrefabInstance(director))
PrefabUtility.RecordPrefabInstancePropertyModifications(director);
#if UNITY_2021_2_OR_NEWER
if (!EditorApplication.isPlaying && director.gameObject != null && director.gameObject.scene.IsValid())
EditorSceneManager.MarkSceneDirty(director.gameObject.scene);
#endif
}
///
/// 选中物体所属 Prefab 资源所在目录下的 timeline 文件夹(Assets 路径);不存在则创建。
///
private static bool TryGetOrCreateTimelineFolderNearPrefab(GameObject boss, out string assetsFolder, out string hint)
{
assetsFolder = null;
hint = null;
var go = boss;
// 与 AutoBindTimelineByFieldNameMenu 一致:实例用 NearestInstanceRoot;Prefab 编辑模式用 PrefabStage.assetPath(无 FirstInstanceRoot API)
var prefabAssetPath = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(go);
if (string.IsNullOrEmpty(prefabAssetPath))
{
var outer = PrefabUtility.GetOutermostPrefabInstanceRoot(go);
if (outer != null)
prefabAssetPath = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(outer);
}
if (string.IsNullOrEmpty(prefabAssetPath))
{
var prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
if (prefabStage != null)
prefabAssetPath = prefabStage.assetPath;
}
if (string.IsNullOrEmpty(prefabAssetPath))
{
hint = "未解析到 Prefab 资源路径(例如纯场景物体、未保存的临时对象)。";
return false;
}
var prefabDir = Path.GetDirectoryName(prefabAssetPath)?.Replace("\\", "/");
if (string.IsNullOrEmpty(prefabDir) || !prefabDir.StartsWith("Assets/", System.StringComparison.OrdinalIgnoreCase))
{
hint = $"Prefab 目录无效:{prefabDir}";
return false;
}
assetsFolder = $"{prefabDir}/{TimelineSubFolderName}".Replace("\\", "/");
if (AssetDatabase.IsValidFolder(assetsFolder))
return true;
try
{
AssetDatabase.CreateFolder(prefabDir, TimelineSubFolderName);
}
catch (System.Exception e)
{
hint = $"无法在「{prefabDir}」下创建子文件夹「{TimelineSubFolderName}」:{e.Message}";
return false;
}
if (!AssetDatabase.IsValidFolder(assetsFolder))
{
hint = $"创建后仍无法识别文件夹:{assetsFolder}";
return false;
}
AssetDatabase.Refresh();
return true;
}
[MenuItem(MenuPath, true)]
private static bool ValidateGenerateFromSelectedBoss()
{
var go = Selection.activeGameObject;
return go != null && go.GetComponent() != null;
}
private static void AssignPlayableFieldsToBossCellVideoIfAny(Transform spineTf, string assetsFolder, int bossResourceIndex)
{
var cell = spineTf.GetComponent();
if (cell == null)
return;
var so = new SerializedObject(cell);
Undo.RecordObject(cell, "写入 Timeline 引用到 BossCellVideo");
foreach (var (fieldKey, nameSuffix) in new[]
{
("idleTimeline", "idle"),
("deathTimeline", "death"),
("attackTimeline", "attack"),
("attacked1Timeline", "attacked01"),
("attacked2Timeline", "attacked02"),
})
{
var path = $"{assetsFolder.TrimEnd('/')}/bossfight_boss{bossResourceIndex:D2}_{nameSuffix}.playable".Replace("\\", "/");
var ta = AssetDatabase.LoadAssetAtPath(path);
if (ta == null)
continue;
var prop = so.FindProperty(fieldKey);
if (prop != null && prop.propertyType == SerializedPropertyType.ObjectReference)
prop.objectReferenceValue = ta;
}
so.ApplyModifiedProperties();
EditorUtility.SetDirty(cell);
if (PrefabUtility.IsPartOfPrefabInstance(cell))
PrefabUtility.RecordPrefabInstancePropertyModifications(cell);
}
private static void TryCollectBindingPair(TimelineAsset timeline, LeviathanVideoDecoderBase decoder,
List<(TimelineAsset timeline, LeviathanVideoTrack track, LeviathanVideoDecoderBase decoder)> bindingPairs)
{
var track = timeline.GetOutputTracks().OfType().FirstOrDefault();
if (track != null && decoder != null)
bindingPairs.Add((timeline, track, decoder));
}
private static bool TryCreateLeviathanTimeline(string assetPath, string displayBaseName, TextAsset textAsset,
out TimelineAsset timeline, out LeviathanVideoTrack track)
{
timeline = null;
track = null;
var timelineSo = ScriptableObject.CreateInstance();
timelineSo.name = displayBaseName;
AssetDatabase.CreateAsset(timelineSo, assetPath);
timeline = timelineSo;
track = timeline.CreateTrack(null, "Leviathan Video");
var timelineClip = track.CreateClip();
timelineClip.start = 0;
timelineClip.displayName = displayBaseName;
var clipAsset = timelineClip.asset as LeviathanVideoPlayableAsset;
if (clipAsset == null)
{
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 LeviathanVideoDecoderBase FindDecoderUnderLayer(Transform spineRoot, string layerName)
{
var layerTf = FindLayerTransformUnderRoot(spineRoot, layerName);
if (layerTf == null)
return null;
return layerTf.GetComponentInChildren(true);
}
///
/// spine* 下与片段语义对应的同级子物体:idle/death(调用方已合并 die)/attack;attacked01/attacked02 → hit/hit_02。缺则回退 idle。
///
private static LeviathanVideoDecoderBase ResolveBindingDecoderForTimelineSuffix(string timelineFileSuffix,
LeviathanVideoDecoderBase idleDecoder, LeviathanVideoDecoderBase deathDecoder,
LeviathanVideoDecoderBase attackDecoder, LeviathanVideoDecoderBase hitDecoder,
LeviathanVideoDecoderBase hit02Decoder)
{
switch (timelineFileSuffix)
{
case "idle":
return idleDecoder;
case "death":
return deathDecoder ?? idleDecoder;
case "attack":
return attackDecoder ?? idleDecoder;
case "attacked01":
return hitDecoder ?? idleDecoder;
case "attacked02":
return hit02Decoder ?? idleDecoder;
default:
return idleDecoder;
}
}
private static Transform FindLayerTransformUnderRoot(Transform root, string layerName)
{
if (!root)
return null;
var found = root.gameObject.FindChildGameObject(layerName);
if (found)
return found.transform;
foreach (var t in root.GetComponentsInChildren(true))
{
if (t == root)
continue;
if (t.name == layerName)
return t;
}
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 FindTextAssetForBossBytes(int bossIndex, string primarySuffix, string[] fallbackSuffixes)
{
foreach (var baseName in BuildAtlantisBytesBaseNames(bossIndex, primarySuffix))
{
var ta = FindTextAssetByFileName(baseName);
if (ta != null)
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 = FindTextAssetByFileName(baseName);
if (ta != null)
return ta;
}
}
return null;
}
private static TextAsset FindTextAssetByFileName(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 != null)
return asset;
}
return null;
}
private static bool AssetExistsAtPath(string assetPath)
{
return AssetDatabase.LoadAssetAtPath