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

370 lines
16 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.Text.RegularExpressions;
using UnityEditor;
using UnityEngine;
using UnityEngine.Playables;
namespace EventBossFight.Editor
{
/// <summary>
/// 在 Inspector 中右键 <see cref="EventBossFightTimelineBossCellVideo"/>,按物体名解析槽位并绑定 TimelinePlayableAsset
/// <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 bytesatlantis_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