#if UNITY_EDITOR using System.Collections.Generic; using System.IO; using System.Text.RegularExpressions; using UnityEditor; using UnityEngine; using UnityEngine.Playables; namespace EventBossFight.Editor { /// /// 在 Inspector 中右键 ,按物体名解析槽位并绑定 Timeline(PlayableAsset)。 /// 约定:spine0 → 资源 bossfight_boss01_* / atlantis_boss01_*spine1boss02 / atlantis_boss02_*;spine 后数字为从 0 起的槽位,资源 Boss 序号为 槽位+1 /// ZZVideo:在子节点 idle、die、show、attack、hit、hit_02 下查找 ,绑定对应 atlantis_bossXX_*.bytes(die 优先 deathdie;hit/hit_02 对应 hit01/hit02)。 /// public static class EventBossFightTimelineBossCellVideoBindings { /// 从物体名取槽位:整段匹配 spine + 可选下划线 + 数字;spine0 槽位 0 → 资源 boss1。 private static readonly Regex SpineSlotRegex = new Regex(@"^spine_?(\d+)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); /// 备选:名字末尾连续数字,同样视为 0 起槽位(再 +1 对应资源)。 private static readonly Regex TrailingDigitsRegex = new Regex(@"(\d+)\s*$", RegexOptions.CultureInvariant); private const string AssetPrefix = "bossfight_boss"; /// 视频 .bytes 资源前缀,与 Timeline 的 boss 序号规则一致:atlantis_boss01_idle 等。 private const string BytesBossPrefix = "atlantis_boss"; /// 子物体名 → bytes 文件名中间段(与磁盘上的 atlantis_bossXX_*** 一致)。die 子节点优先尝试 death 再 die。 private static readonly (string layerChildName, string bytesSuffix, string[] fallbackSuffixes)[] BytesLayerBindings = { ("idle", "idle", null), ("die", "death", new[] { "die" }), ("show", "show", null), ("attack", "attack", null), ("hit", "hit01", new[] { "attacked01" }), ("hit_02", "hit02", new[] { "attacked02" }), }; private static readonly (string fieldKey, string nameSuffix)[] TimelineSuffixes = { ("showTimeline", "show"), ("idleTimeline", "idle"), ("deathTimeline", "death"), ("attackTimeline", "attack"), ("attacked1Timeline", "attacked01"), ("attacked2Timeline", "attacked02"), }; [MenuItem("CONTEXT/EventBossFightTimelineBossCellVideo/绑定 Timeline 资源(按物体名解析序号)", false, 1500)] private static void BindTimelinesByHostName(MenuCommand command) { var comp = command.context as EventBossFightTimelineBossCellVideo; if (comp == null) return; if (!TryParseBossResourceIndexFromGameObjectName(comp.gameObject.name, out var bossResourceIndex, out var spineSlot, out var parseNote)) { EditorUtility.DisplayDialog( "绑定 Timeline", $"无法从物体名解析 spine 槽位。\n当前名称:「{comp.gameObject.name}」\n\n{parseNote}", "确定"); return; } Undo.RecordObject(comp, $"绑定 bossfight_boss{bossResourceIndex:D2} Timeline 资源"); var missing = false; foreach (var (fieldKey, nameSuffix) in TimelineSuffixes) { var asset = FindPlayableForBossTimeline(bossResourceIndex, nameSuffix); if (asset == null) { var tried = BuildCandidateBaseNames(bossResourceIndex, nameSuffix); Debug.LogWarning($"未找到 PlayableAsset(已尝试:{string.Join("、", tried)})", comp); missing = true; continue; } switch (fieldKey) { case "showTimeline": comp.showTimeline = asset; break; case "idleTimeline": comp.idleTimeline = asset; break; case "deathTimeline": comp.deathTimeline = asset; break; case "attackTimeline": comp.attackTimeline = asset; break; case "attacked1Timeline": comp.attacked1Timeline = asset; break; case "attacked2Timeline": comp.attacked2Timeline = asset; break; } } EditorUtility.SetDirty(comp); if (PrefabUtility.IsPartOfPrefabInstance(comp)) PrefabUtility.RecordPrefabInstancePropertyModifications(comp); Debug.Log( $"已绑定 Timeline:{comp.gameObject.name}(spine 槽位 {spineSlot})→ 资源 boss {bossResourceIndex}(bossfight_boss{bossResourceIndex:D2}_*){(missing ? ",部分资源缺失见 Console" : "")}", comp); } [MenuItem("CONTEXT/EventBossFightTimelineBossCellVideo/绑定 ZZVideo bytes(atlantis_boss + 子节点)", false, 1501)] private static void BindZZVideoBytesUnderLayers(MenuCommand command) { var comp = command.context as EventBossFightTimelineBossCellVideo; if (comp == null) return; if (!TryParseBossResourceIndexFromGameObjectName(comp.gameObject.name, out var bossResourceIndex, out var spineSlot, out var parseNote)) { EditorUtility.DisplayDialog( "绑定 ZZVideo bytes", $"无法从物体名解析 spine 槽位。\n当前名称:「{comp.gameObject.name}」\n\n{parseNote}", "确定"); return; } var decodersToUndo = new List(); var missing = false; foreach (var (layerChildName, bytesSuffix, fallbacks) in BytesLayerBindings) { var layerRoot = FindLayerTransformUnderRoot(comp.transform, layerChildName); if (layerRoot == null) { Debug.LogWarning($"未找到子节点「{layerChildName}」,跳过对应 bytes 绑定。", comp); missing = true; continue; } var decoders = layerRoot.GetComponentsInChildren(true); if (decoders == null || decoders.Length == 0) { Debug.LogWarning($"子节点「{layerChildName}」下未找到 ZZVideoRawImageDecoder,跳过。", comp); missing = true; continue; } var textAsset = FindTextAssetForBossBytes(bossResourceIndex, bytesSuffix, fallbacks); if (textAsset == null) { var tried = ListTriedAtlantisBytesNames(bossResourceIndex, bytesSuffix, fallbacks); Debug.LogWarning($"未找到 TextAsset(已尝试:{string.Join("、", tried)})", layerRoot.gameObject); missing = true; continue; } foreach (var decoder in decoders) { if (decoder == null) continue; decodersToUndo.Add(decoder); } } if (decodersToUndo.Count > 0) Undo.RecordObjects(decodersToUndo.ToArray(), $"绑定 atlantis_boss{bossResourceIndex:D2} ZZVideo bytes"); foreach (var (layerChildName, bytesSuffix, fallbacks) in BytesLayerBindings) { var layerRoot = FindLayerTransformUnderRoot(comp.transform, layerChildName); if (layerRoot == null) continue; var decoders = layerRoot.GetComponentsInChildren(true); if (decoders == null || decoders.Length == 0) continue; var textAsset = FindTextAssetForBossBytes(bossResourceIndex, bytesSuffix, fallbacks); if (textAsset == null) continue; foreach (var decoder in decoders) { if (decoder == null) continue; decoder.bytesFile = textAsset; EditorUtility.SetDirty(decoder); if (PrefabUtility.IsPartOfPrefabInstance(decoder)) PrefabUtility.RecordPrefabInstancePropertyModifications(decoder); } EditorUtility.SetDirty(layerRoot.gameObject); } EditorUtility.SetDirty(comp); if (PrefabUtility.IsPartOfPrefabInstance(comp)) PrefabUtility.RecordPrefabInstancePropertyModifications(comp); AssetDatabase.SaveAssets(); Debug.Log( $"已绑定 ZZVideo bytes:{comp.gameObject.name}(spine 槽位 {spineSlot})→ atlantis_boss{bossResourceIndex:D2}_*(各层子节点下 Decoder){(missing ? ",部分层或资源缺失见 Console" : "")}", comp); } /// 与运行时 的 ResolveVideoLayer 一致:先 FindChildGameObject 再深度按名匹配。 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 IEnumerable ListTriedAtlantisBytesNames(int bossIndex, string primarySuffix, string[] fallbackSuffixes) { foreach (var n in BuildAtlantisBytesBaseNames(bossIndex, primarySuffix)) yield return n; if (fallbackSuffixes == null) yield break; foreach (var suf in fallbackSuffixes) { foreach (var n in BuildAtlantisBytesBaseNames(bossIndex, suf)) yield return n; } } 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; } /// /// 供测试或其它工具调用:解析 spine 槽位(0 起)及用于文件名的 Boss 序号(1 起,= 槽位 + 1)。 /// public static bool TryParseBossResourceIndexFromGameObjectName( string gameObjectName, out int bossResourceIndex, out int spineSlot, out string note) { bossResourceIndex = 0; spineSlot = 0; note = "请使用 spine + 数字(从 0 起),例如 spine0→boss1、spine9→boss10;或名称末尾为槽位数字。"; if (string.IsNullOrEmpty(gameObjectName)) return false; var trimmed = gameObjectName.Trim(); var m = SpineSlotRegex.Match(trimmed); if (m.Success && int.TryParse(m.Groups[1].Value, out spineSlot) && spineSlot >= 0) { bossResourceIndex = spineSlot + 1; return true; } var m2 = TrailingDigitsRegex.Match(trimmed); if (m2.Success && int.TryParse(m2.Groups[1].Value, out spineSlot) && spineSlot >= 0) { bossResourceIndex = spineSlot + 1; note = "已用名称末尾数字作为 spine 槽位(0 起),资源名为 boss(槽位+1)(未匹配到 spine 前缀时)。"; return true; } return false; } private static IEnumerable BuildCandidateBaseNames(int bossIndex, string suffix) { var d2 = $"{AssetPrefix}{bossIndex:D2}_{suffix}"; var plain = $"{AssetPrefix}{bossIndex}_{suffix}"; if (d2 == plain) yield return d2; else { yield return d2; yield return plain; } } private static PlayableAsset FindPlayableForBossTimeline(int bossIndex, string suffix) { foreach (var baseName in BuildCandidateBaseNames(bossIndex, suffix)) { var a = FindPlayableAssetByFileName(baseName); if (a != null) return a; } return null; } /// 按「不含扩展名」的文件名精确匹配工程内 PlayableAsset(含 Timeline)。 private static PlayableAsset FindPlayableAssetByFileName(string fileNameWithoutExtension) { var guids = AssetDatabase.FindAssets($"{fileNameWithoutExtension} t:PlayableAsset"); 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; } } } #endif