#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_*,spine1 → boss02 / atlantis_boss02_*;spine 后数字为从 0 起的槽位,资源 Boss 序号为 槽位+1。
/// ZZVideo:在子节点 idle、die、show、attack、hit、hit_02 下查找 ,绑定对应 atlantis_bossXX_*.bytes(die 优先 death 再 die;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