#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)/attackattacked01/attacked02hit/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(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