UnityVATTool/Assets/Common/Scripts/Editor/MonsterVATBatchBaker.cs

422 lines
18 KiB
C#

/*
* MonsterVATBatchBaker
*
* 一键怪物 VAT 烘焙工具:
* 输入 — Assets/_External/Monsters/<name>/<name>.FBX 主 FBX(必须 Legacy animationType=1)
* 流程 —
* 1) 临时场景中实例化主 FBX
* 2) 给 root 加 Animation 组件
* 3) 从同目录的 <name>@Run.FBX 拿 Legacy Run AnimationClip,AddClip 到 Animation
* 4) 反射调用 SelectiveVATBakerWindow.BakeAll(自动 X=-90 if Z-up)
* 5) 销毁场景实例
* 输出 — Assets/_VAT_Baking/Output/<name>/<name>_VAT.prefab + Atlas + Database
*
* 用法:
* - Project 窗口里选中一个或多个主 FBX(必须在 _External/Monsters 或 _External/Bosses 下),
* 菜单 "Tools/OctoberStudio/VAT/Bake Selected Monsters (Run-only)" 即可。
* - 或者直接选中 _External/Monsters 文件夹批量处理。
*/
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using UnityEditor;
using UnityEngine;
namespace OctoberStudio.EditorTools
{
public static class MonsterVATBatchBaker
{
private const string OutputRoot = "Assets/_VAT_Baking/Output";
private static readonly string[] AnimNameCandidates = { "Run", "run", "RUN" };
// 测试入口: 直接烤 mon1001 (用 Resources/Role/Monster 下已配置的 .prefab)
[MenuItem("Tools/OctoberStudio/VAT/Bake mon1001 (Test Sample)", priority = 59)]
public static void BakeMon1001Sample()
{
BakePrefab("Assets/Resources/Role/Monster/mon1001.prefab");
}
private const string MonsterPrefabsDir = "Assets/Resources/Role/Monster";
// 批量烤 Resources/Role/Monster/*.prefab 全部小怪 (使用配好 Animation + SMR 的 prefab,
// 不需要外挂 @Run.FBX 文件查找)
[MenuItem("Tools/OctoberStudio/VAT/Bake ALL Monsters in Resources", priority = 58)]
public static void BakeAllResourcesMonsters()
{
if (!AssetDatabase.IsValidFolder(MonsterPrefabsDir))
{
Debug.LogError($"[BatchBake] 目录不存在: {MonsterPrefabsDir}");
return;
}
// 找该目录下所有 .prefab(不递归子目录,直接 1 层)
var guids = AssetDatabase.FindAssets("t:Prefab", new[] { MonsterPrefabsDir });
var paths = new List<string>();
foreach (var g in guids)
{
var p = AssetDatabase.GUIDToAssetPath(g);
// 限定在该目录下 (FindAssets 默认是递归,这里再过滤一道)
if (p.StartsWith(MonsterPrefabsDir + "/") &&
Path.GetDirectoryName(p)?.Replace("\\", "/") == MonsterPrefabsDir)
{
paths.Add(p);
}
}
paths.Sort();
if (paths.Count == 0)
{
Debug.LogWarning($"[BatchBake] {MonsterPrefabsDir} 下没找到 prefab");
return;
}
Debug.Log($"[BatchBake] 共 {paths.Count} 只小怪开始烤: " + string.Join(", ", paths.Select(Path.GetFileNameWithoutExtension)));
int ok = 0, skip = 0, fail = 0;
var failList = new List<string>();
for (int i = 0; i < paths.Count; i++)
{
var name = Path.GetFileNameWithoutExtension(paths[i]);
if (EditorUtility.DisplayCancelableProgressBar(
"Bake ALL Monsters", $"[{i + 1}/{paths.Count}] {name}",
(float)i / paths.Count))
{
Debug.LogWarning("[BatchBake] 用户取消");
break;
}
// 已烤过(目录存在)就跳过,避免覆盖手工调整的 prefab。强制重烤请删 OutputDir/<name>
var outPath = $"{OutputRoot}/{name}";
if (AssetDatabase.IsValidFolder(outPath))
{
Debug.Log($"[BatchBake] 跳过(已存在): {outPath}");
skip++;
continue;
}
try
{
BakePrefab(paths[i]);
ok++;
}
catch (Exception e)
{
Debug.LogError($"[BatchBake] 失败 {name}: {e.Message}");
fail++;
failList.Add($"{name}: {e.Message}");
}
}
EditorUtility.ClearProgressBar();
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log($"[BatchBake] Done — OK={ok}, Skip={skip}, Fail={fail}");
if (failList.Count > 0)
Debug.LogWarning("[BatchBake] 失败列表:\n" + string.Join("\n", failList));
}
// 给后续批量烤怪用 — 直接用已配置 prefab,不需要外挂 Animation 组件
public static void BakePrefab(string prefabPath)
{
try
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
if (prefab == null) { Debug.LogError($"[BakePrefab] 找不到: {prefabPath}"); return; }
var instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab);
if (instance == null) { Debug.LogError("[BakePrefab] Instantiate failed"); return; }
try
{
// 关 Animator(如果存在),避免和 Animation 双源冲突
var animator = instance.GetComponentInChildren<Animator>();
if (animator != null) animator.enabled = false;
var anim = instance.GetComponent<Animation>();
if (anim == null)
{
// root 没 Animation 时,从子节点提
anim = instance.GetComponentInChildren<Animation>();
if (anim == null) { Debug.LogError("[BakePrefab] 没找到 Animation 组件"); return; }
}
var smr = instance.GetComponentInChildren<SkinnedMeshRenderer>();
if (smr == null) { Debug.LogError("[BakePrefab] 没找到 SkinnedMeshRenderer"); return; }
Debug.Log($"[BakePrefab] {Path.GetFileNameWithoutExtension(prefabPath)}: mesh={smr.sharedMesh?.name} verts={smr.sharedMesh?.vertexCount}");
// 如果 Animation 在子节点上,我们需要把它搬到 root 才能让 baker 用
// 但 baker 用 _target.GetComponent<Animation>(),所以 root 必须有 Animation
if (instance.GetComponent<Animation>() == null && anim != null)
{
// 把子节点 Animation 引用的 clip 抽出来,在 root 加 Animation 组件
var newAnim = instance.AddComponent<Animation>();
foreach (AnimationState st in anim) newAnim.AddClip(st.clip, st.clip.name);
var firstClip = anim.clip;
if (firstClip != null) newAnim.clip = firstClip;
}
BakeViaSelectiveBaker(instance);
}
finally
{
UnityEngine.Object.DestroyImmediate(instance);
}
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
catch (Exception e)
{
Debug.LogError($"[BakePrefab] Exception: {e}");
}
}
[MenuItem("Tools/OctoberStudio/VAT/Bake Selected Monsters (Run-only)", priority = 60)]
public static void BakeSelected()
{
BakeFromPaths(CollectFbxFromSelection().ToArray());
}
private static void BakeFromPaths(string[] paths)
{
var fbxList = paths != null ? paths.ToList() : new List<string>();
if (fbxList.Count == 0)
{
Debug.LogWarning("[BakeMonsters] 没有 FBX 路径输入。");
return;
}
int ok = 0, fail = 0;
var failList = new List<string>();
for (int i = 0; i < fbxList.Count; i++)
{
var path = fbxList[i];
if (EditorUtility.DisplayCancelableProgressBar(
"Bake Monsters", $"[{i + 1}/{fbxList.Count}] {Path.GetFileName(path)}",
(float)i / fbxList.Count))
{
Debug.LogWarning("[BakeMonsters] User cancelled.");
break;
}
try
{
BakeOne(path);
ok++;
}
catch (Exception e)
{
Debug.LogError($"[BakeMonsters] Failed: {path}\n{e}");
fail++;
failList.Add($"{Path.GetFileNameWithoutExtension(path)}: {e.Message}");
}
}
EditorUtility.ClearProgressBar();
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log($"[BakeMonsters] Done. OK={ok}, Fail={fail}");
if (failList.Count > 0)
Debug.LogWarning("[BakeMonsters] 失败列表:\n" + string.Join("\n", failList));
}
private static List<string> CollectFbxFromSelection()
{
var paths = new List<string>();
foreach (var obj in Selection.objects)
{
var p = AssetDatabase.GetAssetPath(obj);
if (string.IsNullOrEmpty(p)) continue;
if (Directory.Exists(p))
{
// 文件夹: 递归找主 FBX (不含 @)
var allFbx = AssetDatabase.FindAssets("t:Model", new[] { p });
foreach (var g in allFbx)
{
var path = AssetDatabase.GUIDToAssetPath(g);
var name = Path.GetFileName(path);
if (!name.Contains("@") && !paths.Contains(path)) paths.Add(path);
}
}
else if (p.EndsWith(".fbx", StringComparison.OrdinalIgnoreCase) ||
p.EndsWith(".FBX", StringComparison.OrdinalIgnoreCase))
{
var name = Path.GetFileName(p);
if (!name.Contains("@") && !paths.Contains(p)) paths.Add(p);
}
}
return paths;
}
private static void BakeOne(string fbxAssetPath)
{
// 1) 加载主 FBX
var fbx = AssetDatabase.LoadAssetAtPath<GameObject>(fbxAssetPath);
if (fbx == null) throw new Exception("FBX not loaded: " + fbxAssetPath);
// 2) 找同目录的 @Run.FBX 中的 Run clip
var runClip = FindRunClip(fbxAssetPath);
if (runClip == null)
throw new Exception("找不到 Run AnimationClip。请确保同目录有 <name>@Run.FBX 且其中含 Legacy Run clip");
// 3) 实例化到临时隐藏 GameObject (不入场景层级,但 baker 需要 SMR + Animation)
// PrefabUtility.InstantiatePrefab 不能直接 hideFlags,但我们烤完立刻销毁,无所谓
var instance = (GameObject)PrefabUtility.InstantiatePrefab(fbx);
if (instance == null) throw new Exception("Instantiate failed: " + fbxAssetPath);
try
{
// 4) 确保有 SkinnedMeshRenderer
var smr = instance.GetComponentInChildren<SkinnedMeshRenderer>();
if (smr == null) throw new Exception("FBX 内找不到 SkinnedMeshRenderer: " + fbxAssetPath);
// 5) 加 Animation 组件 (主 FBX importAnimation=0 时根上没有 Animation)
var anim = instance.GetComponent<Animation>();
if (anim == null) anim = instance.AddComponent<Animation>();
// 把 Run clip 加入,并设 default clip
if (!runClip.legacy)
throw new Exception("Run clip 不是 Legacy。请在 @Run.FBX Inspector 的 Animation Type 设为 Legacy");
anim.AddClip(runClip, "Run");
anim.clip = runClip;
anim.playAutomatically = false;
Debug.Log($"[BakeOne] {Path.GetFileNameWithoutExtension(fbxAssetPath)}: mesh={smr.sharedMesh?.name} verts={smr.sharedMesh?.vertexCount}, clip={runClip.name} ({runClip.length:F2}s)");
// 6) 反射触发 SelectiveVATBakerWindow.BakeAll
BakeViaSelectiveBaker(instance);
}
finally
{
UnityEngine.Object.DestroyImmediate(instance);
}
}
private static AnimationClip FindRunClip(string mainFbxPath)
{
var dir = Path.GetDirectoryName(mainFbxPath)?.Replace("\\", "/");
var baseName = Path.GetFileNameWithoutExtension(mainFbxPath);
if (string.IsNullOrEmpty(dir) || string.IsNullOrEmpty(baseName)) return null;
// 候选: <name>@Run.FBX, <name>@run.FBX
foreach (var suffix in AnimNameCandidates)
{
var p = $"{dir}/{baseName}@{suffix}.FBX";
var clip = LoadFirstClipFromFbx(p);
if (clip != null) return clip;
}
// 或者主 FBX 自带 Run clip
return LoadFirstClipFromFbx(mainFbxPath, "Run");
}
private static AnimationClip LoadFirstClipFromFbx(string fbxPath, string preferName = null)
{
if (!File.Exists(Path.GetFullPath(fbxPath))) return null;
var all = AssetDatabase.LoadAllAssetsAtPath(fbxPath);
AnimationClip first = null;
foreach (var a in all)
{
if (a is AnimationClip ac && !ac.name.StartsWith("__preview__"))
{
if (!string.IsNullOrEmpty(preferName) && ac.name.IndexOf(preferName, StringComparison.OrdinalIgnoreCase) >= 0)
return ac;
if (first == null) first = ac;
}
}
return first;
}
// 反射 — SelectiveVATBakerWindow 不在我们命名空间,但脚本可访问 ScriptableObject + 反射
private static void BakeViaSelectiveBaker(GameObject target)
{
// 类型
var bakerType = GetTypeByName("SelectiveVATBakerWindow");
if (bakerType == null) throw new Exception("找不到 SelectiveVATBakerWindow 类型");
var win = ScriptableObject.CreateInstance(bakerType);
try
{
// _target = target
SetField(win, "_target", target);
// 自动 Z-up 检测
var smr = target.GetComponentInChildren<SkinnedMeshRenderer>();
if (smr != null && smr.sharedMesh != null)
{
var size = smr.sharedMesh.bounds.size;
float x = Mathf.Abs(size.x), y = Mathf.Abs(size.y), z = Mathf.Abs(size.z);
bool zUp = z > y && z > x;
SetField(win, "_bakedMeshFacingX", zUp ? -90f : 0f);
}
// _outputRoot
SetField(win, "_outputRoot", OutputRoot);
// Refresh clips + Match defaults (会勾 Idle/Ready/Run/Die)
Invoke(win, "RefreshClipsFromTarget");
Invoke(win, "MatchDefaults");
// 重点: 我们只勾选 Run (强制覆盖默认)
var selectedField = bakerType.GetField("_selected", BindingFlags.NonPublic | BindingFlags.Instance);
if (selectedField != null)
{
var dict = selectedField.GetValue(win) as System.Collections.IDictionary;
if (dict != null)
{
// 先全置 false
var keys = new List<object>();
foreach (var k in dict.Keys) keys.Add(k);
foreach (var k in keys) dict[k] = false;
// 把 name == Run 的设 true
foreach (var k in keys)
{
var ac = k as AnimationClip;
if (ac == null) continue;
if (string.Equals(ac.name, "Run", StringComparison.OrdinalIgnoreCase))
dict[k] = true;
}
}
}
// BakeAll()
Invoke(win, "BakeAll");
}
finally
{
UnityEngine.Object.DestroyImmediate(win);
}
}
// -------- Reflection helpers --------
private static Type GetTypeByName(string typeName)
{
foreach (var a in AppDomain.CurrentDomain.GetAssemblies())
{
foreach (var t in a.GetTypes())
{
if (t.Name == typeName) return t;
}
}
return null;
}
private static void SetField(object obj, string field, object value)
{
var f = obj.GetType().GetField(field, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public);
if (f == null) throw new Exception($"Field not found: {field}");
f.SetValue(obj, value);
}
private static void Invoke(object obj, string method)
{
var m = obj.GetType().GetMethod(method, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public);
if (m == null) throw new Exception($"Method not found: {method}");
m.Invoke(obj, null);
}
}
}