370 lines
16 KiB
C#
370 lines
16 KiB
C#
#if UNITY_EDITOR
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Text.RegularExpressions;
|
||
using UnityEditor;
|
||
using UnityEngine;
|
||
using UnityEngine.Playables;
|
||
|
||
namespace EventBossFight.Editor
|
||
{
|
||
/// <summary>
|
||
/// 在 Inspector 中右键 <see cref="EventBossFightTimelineBossCellVideo"/>,按物体名解析槽位并绑定 Timeline(PlayableAsset)。
|
||
/// <para>约定:<c>spine0</c> → 资源 <c>bossfight_boss01_*</c> / <c>atlantis_boss01_*</c>,<c>spine1</c> → <c>boss02</c> / <c>atlantis_boss02_*</c>;spine 后数字为<strong>从 0 起的槽位</strong>,资源 Boss 序号为 <c>槽位+1</c>。</para>
|
||
/// <para>ZZVideo:在子节点 idle、die、show、attack、hit、hit_02 下查找 <see cref="ZZVideoRawImageDecoder"/>,绑定对应 <c>atlantis_bossXX_*.bytes</c>(die 优先 <c>death</c> 再 <c>die</c>;hit/hit_02 对应 <c>hit01</c>/<c>hit02</c>)。</para>
|
||
/// </summary>
|
||
public static class EventBossFightTimelineBossCellVideoBindings
|
||
{
|
||
/// <summary>从物体名取槽位:整段匹配 spine + 可选下划线 + 数字;<c>spine0</c> 槽位 0 → 资源 boss1。</summary>
|
||
private static readonly Regex SpineSlotRegex = new Regex(@"^spine_?(\d+)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||
|
||
/// <summary>备选:名字末尾连续数字,同样视为 0 起槽位(再 +1 对应资源)。</summary>
|
||
private static readonly Regex TrailingDigitsRegex = new Regex(@"(\d+)\s*$", RegexOptions.CultureInvariant);
|
||
|
||
private const string AssetPrefix = "bossfight_boss";
|
||
|
||
/// <summary>视频 .bytes 资源前缀,与 Timeline 的 boss 序号规则一致:<c>atlantis_boss01_idle</c> 等。</summary>
|
||
private const string BytesBossPrefix = "atlantis_boss";
|
||
|
||
/// <summary>子物体名 → bytes 文件名中间段(与磁盘上的 atlantis_bossXX_*** 一致)。die 子节点优先尝试 death 再 die。</summary>
|
||
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<UnityEngine.Object>();
|
||
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<ZZVideoRawImageDecoder>(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<ZZVideoRawImageDecoder>(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);
|
||
}
|
||
|
||
/// <summary>与运行时 <see cref="EventBossFight.EventBossFightTimelineCellVideo"/> 的 ResolveVideoLayer 一致:先 FindChildGameObject 再深度按名匹配。</summary>
|
||
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 IEnumerable<string> 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<TextAsset>(path);
|
||
if (asset != null)
|
||
return asset;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 供测试或其它工具调用:解析 spine 槽位(0 起)及用于文件名的 Boss 序号(1 起,= 槽位 + 1)。
|
||
/// </summary>
|
||
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<string> 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;
|
||
}
|
||
|
||
/// <summary>按「不含扩展名」的文件名精确匹配工程内 PlayableAsset(含 Timeline)。</summary>
|
||
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<PlayableAsset>(path);
|
||
if (asset != null)
|
||
return asset;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
#endif
|