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

519 lines
23 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 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
{
/// <summary>
/// 在选中带 <see cref="PlayableDirector"/> 的 <c>boss</c> 节点上,按子物体 <c>spine0</c>… 批量生成
/// <c>bossfight_boss01_attack.playable</c> 等 Timeline一条 <see cref="LeviathanVideoTrack"/> + 一个 <see cref="LeviathanVideoPlayableAsset"/>
/// 受击片段Timeline 文件名为 <c>bossfight_bossXX_attacked01.playable</c>,对应 bytes 为 <c>atlantis_bossXX_hit01</c> / <c>hit02</c>(旧名 <c>attacked01</c>/<c>attacked02</c> 可作 fallback
/// Director 绑定:各片段对应 <c>spine*</c> 下<strong>同级</strong>子物体——<c>idle</c>/<c>death</c>(或 <c>die</c>/<c>attack</c>/<c>hit</c>/<c>hit_02</c> 上的解码器;
/// Timeline 文件名仍为 <c>attacked01</c>/<c>attacked02</c>,绑定到 <c>hit</c>/<c>hit_02</c>。缺子物体或无解码器时回退 <c>idle</c>。
/// <para>输出目录默认为<strong>选中物体所属 Prefab 所在目录下的 <c>timeline</c> 子文件夹</strong>;若不存在则自动创建。无法解析 Prefab 时可选手动指定目录。</para>
/// </summary>
public static class EventBossFightAtlantisBossTimelineGenerator
{
private static readonly Regex SpineChildRegex = new Regex(@"^spine_?(\d+)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private const string BytesBossPrefix = "atlantis_boss";
/// <summary>
/// 第 1 项:<c>bossfight_bossXX_{项}.playable</c>;第 23 项:仅用于查找 <c>atlantis_bossXX_{第2项}.bytes</c>(受击 Timeline 名仍为 <c>attacked01</c>bytes 主后缀为 <c>hit01</c>/<c>hit02</c>)。
/// spine 下子物体供 Director 绑定见 <see cref="ResolveBindingDecoderForTimelineSuffix"/>。
/// </summary>
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 TimelineLeviathan 轨 + 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<PlayableDirector>();
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<string>();
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<TimelineAsset>(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);
}
/// <summary>
/// 为每个 Timeline 写入 Director 绑定。结束后<strong>不要</strong>将 playableAsset 置空,否则 Unity 会丢掉已序列化的 Scene Bindings。
/// 默认把 playableAsset 设为最小槽位 spine 对应的 idle便于在 Timeline 窗口里直接预览。
/// </summary>
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<TimelineAsset>(path);
if (tl == null)
continue;
var tr = tl.GetOutputTracks().OfType<LeviathanVideoTrack>().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<TimelineAsset>(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
}
/// <summary>
/// 选中物体所属 Prefab 资源所在目录下的 <c>timeline</c> 文件夹Assets 路径);不存在则创建。
/// </summary>
private static bool TryGetOrCreateTimelineFolderNearPrefab(GameObject boss, out string assetsFolder, out string hint)
{
assetsFolder = null;
hint = null;
var go = boss;
// 与 AutoBindTimelineByFieldNameMenu 一致:实例用 NearestInstanceRootPrefab 编辑模式用 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<PlayableDirector>() != null;
}
private static void AssignPlayableFieldsToBossCellVideoIfAny(Transform spineTf, string assetsFolder, int bossResourceIndex)
{
var cell = spineTf.GetComponent<EventBossFightTimelineBossCellVideo>();
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<TimelineAsset>(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<LeviathanVideoTrack>().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<TimelineAsset>();
timelineSo.name = displayBaseName;
AssetDatabase.CreateAsset(timelineSo, assetPath);
timeline = timelineSo;
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 == 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<LeviathanVideoDecoderBase>(true);
}
/// <summary>
/// <c>spine*</c> 下与片段语义对应的<strong>同级</strong>子物体:<c>idle</c>/<c>death</c>(调用方已合并 <c>die</c>/<c>attack</c><c>attacked01</c>/<c>attacked02</c> → <c>hit</c>/<c>hit_02</c>。缺则回退 <c>idle</c>。
/// </summary>
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<Transform>(true))
{
if (t == root)
continue;
if (t.name == layerName)
return t;
}
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 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<TextAsset>(path);
if (asset != null)
return asset;
}
return null;
}
private static bool AssetExistsAtPath(string assetPath)
{
return AssetDatabase.LoadAssetAtPath<Object>(assetPath) != null;
}
private static string AbsolutePathToAssetsPath(string absolutePath)
{
if (string.IsNullOrEmpty(absolutePath))
return null;
absolutePath = absolutePath.Replace("\\", "/");
var dataPath = Application.dataPath.Replace("\\", "/");
if (!absolutePath.StartsWith(dataPath, System.StringComparison.OrdinalIgnoreCase))
return null;
var rel = absolutePath.Substring(dataPath.Length).TrimStart('/');
return string.IsNullOrEmpty(rel) ? "Assets" : "Assets/" + rel;
}
}
}
#endif