/* * MonsterOrientationCheck * * 一键检测 Assets/_External/Monsters 和 Assets/_External/Bosses 下所有怪物 FBX 的朝向。 * 判定逻辑:实例化 FBX 到隐藏临时场景,读 SkinnedMeshRenderer.bounds: * - height = bounds.size.y (Unity 站立高度) * - depth = bounds.size.z * - width = bounds.size.x * 如果 height > max(depth, width) * 1.2 → 站立 OK * 否则需要 X=-90 (从 Z-up 转 Y-up) 或 X=90 * * 输出表格到 Console + 写 Assets/_External/_OrientationReport.txt。 */ using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using UnityEditor; using UnityEngine; namespace OctoberStudio.EditorTools { public static class MonsterOrientationCheck { private const string MonstersDir = "Assets/Res/Role/Monster"; private const string BossesDir = "Assets/Res/Role/Boss"; // 可选,有就扫 private const string ReportPath = "Assets/_OrientationReport.txt"; [MenuItem("Tools/OctoberStudio/VAT/Check Monster Orientation", priority = 50)] public static void Run() { var sb = new StringBuilder(); sb.AppendLine("# Monster/Boss FBX 朝向检测报告"); sb.AppendLine($"# 生成时间: {System.DateTime.Now:yyyy-MM-dd HH:mm:ss}"); sb.AppendLine("# 判定:height 是 size.y; 若 height 不是最大维度,需要 X 校正"); sb.AppendLine(); sb.AppendLine($"{"角色",-30} {"X",8} {"Y",8} {"Z",8} {"主轴",4} {"需要 X 校正?",-15}"); sb.AppendLine(new string('-', 80)); var results = new List(); CollectFromDir(MonstersDir, results); CollectFromDir(BossesDir, results); int needXCorrect = 0; foreach (var r in results.OrderBy(e => e.Category).ThenBy(e => e.Name)) { string mainAxis = GetMainAxis(r.Size); bool needFix = mainAxis != "Y"; string fixHint = needFix ? (mainAxis == "Z" ? "X=-90 (Z-up)" : (mainAxis == "X" ? "Y=90 (X-up?)" : "?")) : "OK (Y-up)"; if (needFix) needXCorrect++; sb.AppendLine($"{r.Name,-30} {r.Size.x,8:F2} {r.Size.y,8:F2} {r.Size.z,8:F2} {mainAxis,4} {fixHint,-15}"); } sb.AppendLine(); sb.AppendLine($"# 总计: {results.Count}, 需要 X 校正: {needXCorrect}, 直接可用: {results.Count - needXCorrect}"); var text = sb.ToString(); File.WriteAllText(Path.GetFullPath(ReportPath), text); AssetDatabase.ImportAsset(ReportPath); Debug.Log(text); Debug.Log($"[Orientation Check] 报告已写入: {ReportPath}"); } private static void CollectFromDir(string dir, List results) { if (!AssetDatabase.IsValidFolder(dir)) return; // 找每个子目录的主 FBX(不带 @ 的那个) var subDirs = AssetDatabase.GetSubFolders(dir); foreach (var sub in subDirs) { var fbxGuids = AssetDatabase.FindAssets("t:Model", new[] { sub }); foreach (var g in fbxGuids) { var path = AssetDatabase.GUIDToAssetPath(g); var fileName = Path.GetFileName(path); // 跳过 @animation / @Run 这种动画 FBX,只测主 FBX if (fileName.Contains("@")) continue; var fbx = AssetDatabase.LoadAssetAtPath(path); if (fbx == null) continue; var bounds = GetCombinedBounds(fbx); if (bounds.HasValue) { results.Add(new Entry { Category = dir, Name = Path.GetFileNameWithoutExtension(path) + " (" + Path.GetFileName(sub) + ")", Size = bounds.Value.size, Path = path, }); } } } } private static Bounds? GetCombinedBounds(GameObject fbxAsset) { // 临时实例化(不入场景),取 SMR.sharedMesh.bounds(这是 local space,反映 FBX 内部朝向) // 注意: SkinnedMeshRenderer.bounds 是 world space, 但 sharedMesh.bounds 才是本地(模型空间) var smrs = fbxAsset.GetComponentsInChildren(true); var mfs = fbxAsset.GetComponentsInChildren(true); bool inited = false; Bounds b = new Bounds(); foreach (var smr in smrs) { if (smr.sharedMesh == null) continue; if (!inited) { b = smr.sharedMesh.bounds; inited = true; } else b.Encapsulate(smr.sharedMesh.bounds); } foreach (var mf in mfs) { if (mf.sharedMesh == null) continue; if (!inited) { b = mf.sharedMesh.bounds; inited = true; } else b.Encapsulate(mf.sharedMesh.bounds); } return inited ? b : (Bounds?)null; } private static string GetMainAxis(Vector3 size) { float x = Mathf.Abs(size.x); float y = Mathf.Abs(size.y); float z = Mathf.Abs(size.z); if (y >= x && y >= z) return "Y"; if (z >= x && z >= y) return "Z"; return "X"; } private class Entry { public string Category; public string Name; public Vector3 Size; public string Path; } } }