/* * MonsterVATBatchBaker * * 一键怪物 VAT 烘焙工具: * 输入 — Assets/_External/Monsters//.FBX 主 FBX(必须 Legacy animationType=1) * 流程 — * 1) 临时场景中实例化主 FBX * 2) 给 root 加 Animation 组件 * 3) 从同目录的 @Run.FBX 拿 Legacy Run AnimationClip,AddClip 到 Animation * 4) 反射调用 SelectiveVATBakerWindow.BakeAll(自动 X=-90 if Z-up) * 5) 销毁场景实例 * 输出 — Assets/_VAT_Baking/Output//_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(); 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(); 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/ 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(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(); if (animator != null) animator.enabled = false; var anim = instance.GetComponent(); if (anim == null) { // root 没 Animation 时,从子节点提 anim = instance.GetComponentInChildren(); if (anim == null) { Debug.LogError("[BakePrefab] 没找到 Animation 组件"); return; } } var smr = instance.GetComponentInChildren(); 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(),所以 root 必须有 Animation if (instance.GetComponent() == null && anim != null) { // 把子节点 Animation 引用的 clip 抽出来,在 root 加 Animation 组件 var newAnim = instance.AddComponent(); 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(); if (fbxList.Count == 0) { Debug.LogWarning("[BakeMonsters] 没有 FBX 路径输入。"); return; } int ok = 0, fail = 0; var failList = new List(); 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 CollectFbxFromSelection() { var paths = new List(); 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(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。请确保同目录有 @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(); if (smr == null) throw new Exception("FBX 内找不到 SkinnedMeshRenderer: " + fbxAssetPath); // 5) 加 Animation 组件 (主 FBX importAnimation=0 时根上没有 Animation) var anim = instance.GetComponent(); if (anim == null) anim = instance.AddComponent(); // 把 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; // 候选: @Run.FBX, @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(); 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(); 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); } } }