1623 lines
56 KiB
C#
1623 lines
56 KiB
C#
using System.Collections.Concurrent;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using UnityEditor;
|
||
using UnityEngine;
|
||
|
||
public class HumanoidBoneMismatchChecker : EditorWindow
|
||
{
|
||
// 参考对象(可以是Prefab或Hierarchy中的对象)
|
||
private GameObject referenceObject;
|
||
|
||
// 从参考对象读取的实际骨骼结构(带完整路径)
|
||
private List<string> actualBoneFullPaths = new List<string>();
|
||
private List<string> actualBoneNames = new List<string>();
|
||
private Dictionary<string, string> bonePathToName = new Dictionary<string, string>();
|
||
|
||
private List<AnimationClip> clips = new List<AnimationClip>();
|
||
|
||
private Dictionary<AnimationClip, List<string>> mismatchDetails = new Dictionary<AnimationClip, List<string>>();
|
||
private Dictionary<AnimationClip, int> mismatchCountMap = new Dictionary<AnimationClip, int>();
|
||
private Dictionary<AnimationClip, float> matchPercentageMap = new Dictionary<AnimationClip, float>();
|
||
private Dictionary<AnimationClip, List<string>> animationBonesCache = new Dictionary<AnimationClip, List<string>>();
|
||
|
||
// 多条件检测设置
|
||
private bool useMismatchCount = true;
|
||
private bool useMatchPercentage = true;
|
||
private bool useRequiredBonesList = false;
|
||
private bool useMinBoneCount = false;
|
||
|
||
private int mismatchCountThreshold = 3;
|
||
private float matchPercentageRequired = 80f;
|
||
private int minBoneCountRequired = 10;
|
||
private List<string> requiredBonesList = new List<string>();
|
||
private string newRequiredBone = "";
|
||
private Vector2 requiredBonesScrollPos;
|
||
|
||
// 匹配模式
|
||
private bool useFullPathMatch = true;
|
||
|
||
// 判断逻辑
|
||
private bool useAndLogic = true;
|
||
|
||
private Vector2 clipListScrollPos;
|
||
private Vector2 resultScrollPos;
|
||
private Vector2 boneListScrollPos;
|
||
|
||
private string rootFolder = "Assets/Animations";
|
||
private string goodSubFolder = "good";
|
||
private string badSubFolder = "bad";
|
||
private bool exportBadClips = true;
|
||
|
||
private bool hasRunCheck = false;
|
||
private bool showAdvancedInfo = false;
|
||
private bool showBoneList = true;
|
||
private bool showConditionSettings = true;
|
||
|
||
// 异步处理
|
||
private bool isProcessing = false;
|
||
private float progress = 0f;
|
||
private string currentProgressText = "";
|
||
private Task currentTask;
|
||
|
||
// 缓存数据用于异步处理
|
||
private struct ClipData
|
||
{
|
||
public string clipPath;
|
||
public string clipName;
|
||
public List<string> bonePaths; // 预缓存的骨骼路径
|
||
}
|
||
|
||
private List<ClipData> cachedClipData = new List<ClipData>();
|
||
|
||
[MenuItem("Tools/动画骨骼检测工具")]
|
||
public static void ShowWindow()
|
||
{
|
||
GetWindow<HumanoidBoneMismatchChecker>("动画骨骼检测工具");
|
||
}
|
||
Vector2 scrollPos;
|
||
private void OnGUI()
|
||
{
|
||
// 整体滚动区域
|
||
scrollPos = EditorGUILayout.BeginScrollView(scrollPos);
|
||
|
||
GUILayout.Label("动画骨骼检测工具", EditorStyles.boldLabel);
|
||
|
||
// 1. 参考对象
|
||
DrawReferenceObjectSection();
|
||
GUILayout.Space(10);
|
||
|
||
// 2. 动画列表
|
||
DrawClipList();
|
||
GUILayout.Space(10);
|
||
|
||
// 3. 条件设置
|
||
DrawConditionSettings();
|
||
GUILayout.Space(10);
|
||
|
||
// 4. 检测按钮
|
||
if (!isProcessing)
|
||
{
|
||
if (GUILayout.Button("开始检测动画", GUILayout.Height(40)))
|
||
RunCheckAsync();
|
||
}
|
||
else
|
||
{
|
||
DrawProgressBar();
|
||
EditorGUILayout.EndScrollView();
|
||
return;
|
||
}
|
||
|
||
GUILayout.Space(10);
|
||
|
||
// 5. 检测结果
|
||
GUILayout.Label("检测结果", EditorStyles.boldLabel);
|
||
DrawResultList();
|
||
|
||
GUILayout.Space(20);
|
||
|
||
EditorGUILayout.EndScrollView();
|
||
|
||
// 6. 导出区域(固定在底部,不随滚动)
|
||
GUILayout.Space(10);
|
||
EditorGUILayout.BeginVertical("box");
|
||
GUILayout.Label("导出设置", EditorStyles.boldLabel);
|
||
|
||
rootFolder = EditorGUILayout.TextField("导出根目录", rootFolder);
|
||
goodSubFolder = EditorGUILayout.TextField("适配子目录", goodSubFolder);
|
||
badSubFolder = EditorGUILayout.TextField("不适配子目录", badSubFolder);
|
||
exportBadClips = EditorGUILayout.Toggle("导出不适配动画", exportBadClips);
|
||
|
||
GUILayout.Space(10);
|
||
|
||
if (GUILayout.Button("导出动画", GUILayout.Height(35)))
|
||
ExportResults();
|
||
|
||
EditorGUILayout.EndVertical();
|
||
}
|
||
|
||
|
||
private void DrawProgressBar()
|
||
{
|
||
GUILayout.Label("正在检测动画...", EditorStyles.boldLabel);
|
||
GUILayout.Space(10);
|
||
|
||
EditorGUILayout.HelpBox(currentProgressText, MessageType.Info);
|
||
|
||
Rect rect = GUILayoutUtility.GetRect(18, 18, "TextField");
|
||
EditorGUI.ProgressBar(rect, progress, $"{progress * 100:F1}%");
|
||
|
||
GUILayout.Space(10);
|
||
|
||
if (GUILayout.Button("取消", GUILayout.Height(30)))
|
||
{
|
||
isProcessing = false;
|
||
if (currentTask != null)
|
||
{
|
||
currentTask = null;
|
||
}
|
||
Repaint();
|
||
}
|
||
}
|
||
|
||
// -----------------------------
|
||
// 刷新动画缓存(修复:刷新界面 + 清空旧结果)
|
||
// -----------------------------
|
||
private void RefreshAnimationCache()
|
||
{
|
||
if (isProcessing) return;
|
||
|
||
Debug.Log("开始刷新动画缓存...");
|
||
PreCacheAnimationBones();
|
||
Debug.Log($"动画缓存刷新完成!共缓存 {cachedClipData.Count} 个动画");
|
||
|
||
// 清空旧检测结果
|
||
ClearResults();
|
||
|
||
// 强制刷新界面
|
||
Repaint();
|
||
|
||
EditorUtility.DisplayDialog(
|
||
"刷新完成",
|
||
$"动画缓存刷新完成!\n共缓存 {cachedClipData.Count} 个动画",
|
||
"确定"
|
||
);
|
||
}
|
||
|
||
|
||
// -----------------------------
|
||
// 参考对象选择区域
|
||
// -----------------------------
|
||
private void DrawReferenceObjectSection()
|
||
{
|
||
GUILayout.Label("1. 选择参考对象(骨骼来源)", EditorStyles.boldLabel);
|
||
|
||
Rect dropArea = GUILayoutUtility.GetRect(0, 50, GUILayout.ExpandWidth(true));
|
||
GUI.Box(dropArea, "拖拽 GameObject 到这里\n(可从Hierarchy或Project拖入)");
|
||
|
||
HandleReferenceObjectDragDrop(dropArea);
|
||
|
||
EditorGUI.BeginChangeCheck();
|
||
referenceObject = (GameObject)EditorGUILayout.ObjectField("参考对象", referenceObject, typeof(GameObject), true);
|
||
|
||
if (EditorGUI.EndChangeCheck() && referenceObject != null)
|
||
{
|
||
LoadGameObjectBones(referenceObject);
|
||
ClearResults();
|
||
}
|
||
|
||
if (referenceObject != null && actualBoneFullPaths.Count > 0)
|
||
{
|
||
string sourceType = PrefabUtility.IsPartOfAnyPrefab(referenceObject) ? "Prefab" : "场景对象";
|
||
EditorGUILayout.HelpBox($"✓ 已加载: {referenceObject.name} ({sourceType})\n骨骼数量: {actualBoneFullPaths.Count}", MessageType.Info);
|
||
|
||
EditorGUILayout.BeginHorizontal();
|
||
if (GUILayout.Button("刷新骨骼列表", GUILayout.Width(100)))
|
||
{
|
||
LoadGameObjectBones(referenceObject);
|
||
}
|
||
if (GUILayout.Button("显示骨骼层级", GUILayout.Width(100)))
|
||
{
|
||
ShowBoneHierarchy();
|
||
}
|
||
EditorGUILayout.EndHorizontal();
|
||
}
|
||
else if (referenceObject != null && actualBoneFullPaths.Count == 0)
|
||
{
|
||
EditorGUILayout.HelpBox("⚠️ 未能从该对象读取到骨骼结构,请确保对象包含骨骼层级", MessageType.Warning);
|
||
}
|
||
else
|
||
{
|
||
EditorGUILayout.HelpBox("请拖入一个带骨骼结构的GameObject(场景中的角色或Prefab)", MessageType.Warning);
|
||
}
|
||
}
|
||
|
||
private void HandleReferenceObjectDragDrop(Rect dropArea)
|
||
{
|
||
Event evt = Event.current;
|
||
if (!dropArea.Contains(evt.mousePosition))
|
||
return;
|
||
|
||
switch (evt.type)
|
||
{
|
||
case EventType.DragUpdated:
|
||
DragAndDrop.visualMode = DragAndDropVisualMode.Copy;
|
||
evt.Use();
|
||
break;
|
||
|
||
case EventType.DragPerform:
|
||
DragAndDrop.AcceptDrag();
|
||
foreach (Object obj in DragAndDrop.objectReferences)
|
||
{
|
||
if (obj is GameObject go)
|
||
{
|
||
referenceObject = go;
|
||
LoadGameObjectBones(referenceObject);
|
||
ClearResults();
|
||
break;
|
||
}
|
||
}
|
||
evt.Use();
|
||
break;
|
||
}
|
||
}
|
||
|
||
private void ClearResults()
|
||
{
|
||
mismatchDetails.Clear();
|
||
mismatchCountMap.Clear();
|
||
matchPercentageMap.Clear();
|
||
animationBonesCache.Clear();
|
||
hasRunCheck = false;
|
||
}
|
||
|
||
// -----------------------------
|
||
// 加载GameObject的骨骼结构(带完整层级路径)
|
||
// -----------------------------
|
||
private void LoadGameObjectBones(GameObject obj)
|
||
{
|
||
actualBoneFullPaths.Clear();
|
||
actualBoneNames.Clear();
|
||
bonePathToName.Clear();
|
||
|
||
if (obj == null) return;
|
||
|
||
GameObject sourceObject = obj;
|
||
GameObject tempInstance = null;
|
||
|
||
if (PrefabUtility.IsPartOfPrefabAsset(obj) && !PrefabUtility.IsPartOfNonAssetPrefabInstance(obj))
|
||
{
|
||
tempInstance = (GameObject)PrefabUtility.InstantiatePrefab(obj);
|
||
sourceObject = tempInstance;
|
||
}
|
||
|
||
try
|
||
{
|
||
GetAllBonesWithPath(sourceObject.transform, "");
|
||
Debug.Log($"成功从 {(PrefabUtility.IsPartOfAnyPrefab(obj) ? "Prefab" : "场景对象")} 加载 {actualBoneFullPaths.Count} 个骨骼");
|
||
|
||
if (actualBoneFullPaths.Count > 0)
|
||
{
|
||
Debug.Log("骨骼层级示例(前10个):");
|
||
foreach (string path in actualBoneFullPaths.Take(10))
|
||
{
|
||
Debug.Log($" └─ {path}");
|
||
}
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
if (tempInstance != null)
|
||
{
|
||
DestroyImmediate(tempInstance);
|
||
}
|
||
}
|
||
}
|
||
|
||
private void GetAllBonesWithPath(Transform trans, string parentPath)
|
||
{
|
||
string currentPath = string.IsNullOrEmpty(parentPath) ? trans.name : parentPath + "/" + trans.name;
|
||
|
||
actualBoneFullPaths.Add(currentPath);
|
||
actualBoneNames.Add(trans.name);
|
||
bonePathToName[currentPath] = trans.name;
|
||
|
||
foreach (Transform child in trans)
|
||
{
|
||
GetAllBonesWithPath(child, currentPath);
|
||
}
|
||
}
|
||
|
||
private void ShowBoneHierarchy()
|
||
{
|
||
string message = "骨骼层级结构:\n\n";
|
||
foreach (string path in actualBoneFullPaths)
|
||
{
|
||
int depth = path.Count(c => c == '/');
|
||
string indent = new string(' ', depth * 2);
|
||
string boneName = path.Split('/').Last();
|
||
message += $"{indent}└─ {boneName}\n";
|
||
|
||
if (message.Length > 30000)
|
||
{
|
||
message += "\n... (继续显示会过长,已截断)";
|
||
break;
|
||
}
|
||
}
|
||
|
||
EditorUtility.DisplayDialog("骨骼层级结构", message, "确定");
|
||
}
|
||
|
||
// -----------------------------
|
||
// 显示骨骼列表
|
||
// -----------------------------
|
||
private void DrawBoneList()
|
||
{
|
||
showBoneList = EditorGUILayout.Foldout(showBoneList, $"骨骼结构 ({actualBoneFullPaths.Count} 个骨骼,带完整路径)", true);
|
||
|
||
if (showBoneList)
|
||
{
|
||
EditorGUILayout.BeginVertical("box");
|
||
boneListScrollPos = EditorGUILayout.BeginScrollView(boneListScrollPos, GUILayout.Height(150));
|
||
|
||
string search = EditorGUILayout.TextField("搜索:", "");
|
||
|
||
var bonesToShow = actualBoneFullPaths;
|
||
if (!string.IsNullOrEmpty(search))
|
||
{
|
||
bonesToShow = actualBoneFullPaths.Where(b => b.ToLower().Contains(search.ToLower())).ToList();
|
||
}
|
||
|
||
EditorGUILayout.LabelField($"共 {bonesToShow.Count} 个骨骼", EditorStyles.miniLabel);
|
||
|
||
foreach (string bonePath in bonesToShow.Take(80))
|
||
{
|
||
GUILayout.Label($"• {bonePath}", GUILayout.Width(350));
|
||
}
|
||
|
||
if (bonesToShow.Count > 80)
|
||
{
|
||
GUILayout.Label($"... 还有 {bonesToShow.Count - 80} 个骨骼", EditorStyles.miniLabel);
|
||
}
|
||
|
||
EditorGUILayout.EndScrollView();
|
||
EditorGUILayout.EndVertical();
|
||
}
|
||
}
|
||
|
||
// -----------------------------
|
||
// 动画列表
|
||
// -----------------------------
|
||
private void DrawClipList()
|
||
{
|
||
Rect dropArea = GUILayoutUtility.GetRect(0, 60, GUILayout.ExpandWidth(true));
|
||
GUI.Box(dropArea, "拖拽 AnimationClip 到这里\n支持多选拖拽");
|
||
|
||
HandleClipDragDrop(dropArea);
|
||
|
||
GUILayout.BeginHorizontal();
|
||
GUILayout.Label($"当前动画列表 ({clips.Count} 个)", EditorStyles.boldLabel);
|
||
if (GUILayout.Button("清空列表", GUILayout.Width(80)))
|
||
{
|
||
clips.Clear();
|
||
ClearResults();
|
||
cachedClipData.Clear();
|
||
}
|
||
GUILayout.EndHorizontal();
|
||
|
||
if (clips.Count > 0)
|
||
{
|
||
clipListScrollPos = EditorGUILayout.BeginScrollView(clipListScrollPos, GUILayout.Height(100));
|
||
|
||
for (int i = 0; i < clips.Count; i++)
|
||
{
|
||
GUILayout.BeginHorizontal();
|
||
clips[i] = (AnimationClip)EditorGUILayout.ObjectField(clips[i], typeof(AnimationClip), false);
|
||
if (GUILayout.Button("✖", GUILayout.Width(25)))
|
||
{
|
||
var clipToRemove = clips[i];
|
||
clips.RemoveAt(i);
|
||
mismatchDetails.Remove(clipToRemove);
|
||
mismatchCountMap.Remove(clipToRemove);
|
||
matchPercentageMap.Remove(clipToRemove);
|
||
animationBonesCache.Remove(clipToRemove);
|
||
// 同时从缓存中移除
|
||
cachedClipData.RemoveAll(c => c.clipPath == AssetDatabase.GetAssetPath(clipToRemove));
|
||
i--;
|
||
continue;
|
||
}
|
||
GUILayout.EndHorizontal();
|
||
}
|
||
|
||
EditorGUILayout.EndScrollView();
|
||
}
|
||
else
|
||
{
|
||
EditorGUILayout.HelpBox("暂无动画,请拖入 AnimationClip", MessageType.Info);
|
||
}
|
||
|
||
GUILayout.BeginHorizontal();
|
||
if (GUILayout.Button("从文件夹添加"))
|
||
{
|
||
string folder = EditorUtility.OpenFolderPanel("选择动画文件夹", "Assets", "");
|
||
if (!string.IsNullOrEmpty(folder))
|
||
{
|
||
string relativePath = GetRelativePath(folder);
|
||
AddClipsFromFolder(relativePath);
|
||
ClearResults();
|
||
}
|
||
}
|
||
if (GUILayout.Button("添加选中的动画"))
|
||
{
|
||
foreach (var obj in Selection.objects)
|
||
{
|
||
if (obj is AnimationClip clip && !clips.Contains(clip))
|
||
clips.Add(clip);
|
||
}
|
||
ClearResults();
|
||
}
|
||
GUILayout.EndHorizontal();
|
||
}
|
||
|
||
private void HandleClipDragDrop(Rect dropArea)
|
||
{
|
||
Event evt = Event.current;
|
||
if (!dropArea.Contains(evt.mousePosition))
|
||
return;
|
||
|
||
switch (evt.type)
|
||
{
|
||
case EventType.DragUpdated:
|
||
DragAndDrop.visualMode = DragAndDropVisualMode.Copy;
|
||
evt.Use();
|
||
break;
|
||
|
||
case EventType.DragPerform:
|
||
DragAndDrop.AcceptDrag();
|
||
foreach (Object obj in DragAndDrop.objectReferences)
|
||
{
|
||
if (obj is AnimationClip clip && !clips.Contains(clip))
|
||
clips.Add(clip);
|
||
}
|
||
ClearResults();
|
||
evt.Use();
|
||
break;
|
||
}
|
||
}
|
||
|
||
private void AddClipsFromFolder(string folderPath)
|
||
{
|
||
string[] guids = AssetDatabase.FindAssets("t:AnimationClip", new[] { folderPath });
|
||
foreach (string guid in guids)
|
||
{
|
||
string path = AssetDatabase.GUIDToAssetPath(guid);
|
||
AnimationClip clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(path);
|
||
if (clip != null && !clips.Contains(clip))
|
||
clips.Add(clip);
|
||
}
|
||
Debug.Log($"从 {folderPath} 添加了 {guids.Length} 个动画");
|
||
}
|
||
|
||
private string GetRelativePath(string absolutePath)
|
||
{
|
||
string dataPath = Application.dataPath;
|
||
if (absolutePath.StartsWith(dataPath))
|
||
{
|
||
return "Assets" + absolutePath.Substring(dataPath.Length);
|
||
}
|
||
return absolutePath;
|
||
}
|
||
|
||
// -----------------------------
|
||
// 多条件检测设置
|
||
// -----------------------------
|
||
private void DrawConditionSettings()
|
||
{
|
||
showConditionSettings = EditorGUILayout.Foldout(showConditionSettings, "3. 多条件检测设置", true);
|
||
|
||
if (!showConditionSettings) return;
|
||
|
||
EditorGUILayout.BeginVertical("box");
|
||
|
||
// 匹配模式选择
|
||
EditorGUILayout.BeginHorizontal();
|
||
GUILayout.Label("骨骼匹配模式:", GUILayout.Width(100));
|
||
if (GUILayout.Toggle(useFullPathMatch, "完整路径匹配(推荐)", GUILayout.Width(150)))
|
||
useFullPathMatch = true;
|
||
if (GUILayout.Toggle(!useFullPathMatch, "仅名称匹配(宽松)", GUILayout.Width(130)))
|
||
useFullPathMatch = false;
|
||
EditorGUILayout.EndHorizontal();
|
||
|
||
if (useFullPathMatch)
|
||
{
|
||
EditorGUILayout.HelpBox("完整路径匹配:动画中的 \"Hips/Spine/Chest\" 必须与模型中的路径完全一致", MessageType.Info);
|
||
}
|
||
else
|
||
{
|
||
EditorGUILayout.HelpBox("仅名称匹配:只比较骨骼名称,不比较层级路径(可能误匹配)", MessageType.Warning);
|
||
}
|
||
|
||
GUILayout.Space(10);
|
||
|
||
// 判断逻辑选择
|
||
EditorGUILayout.BeginHorizontal();
|
||
GUILayout.Label("可选条件判断逻辑:", GUILayout.Width(120));
|
||
if (GUILayout.Toggle(useAndLogic, "AND(全部满足才算适配)", GUILayout.Width(180)))
|
||
useAndLogic = true;
|
||
if (GUILayout.Toggle(!useAndLogic, "OR(任一满足即适配)", GUILayout.Width(150)))
|
||
useAndLogic = false;
|
||
EditorGUILayout.EndHorizontal();
|
||
|
||
EditorGUILayout.HelpBox(
|
||
"⚠️ 注意:必要骨骼和最小骨骼数是强制条件,不满足直接标记为不适配!",
|
||
MessageType.Warning);
|
||
|
||
GUILayout.Space(10);
|
||
|
||
// ========== 强制条件区域 ==========
|
||
GUILayout.Label("【强制条件】不满足直接标记不适配", EditorStyles.boldLabel);
|
||
|
||
// 条件3:最小骨骼数(强制)
|
||
EditorGUILayout.BeginHorizontal();
|
||
useMinBoneCount = EditorGUILayout.Toggle(useMinBoneCount, GUILayout.Width(20));
|
||
GUILayout.Label("强制条件A:最小骨骼数量", GUILayout.Width(150));
|
||
if (useMinBoneCount)
|
||
{
|
||
minBoneCountRequired = EditorGUILayout.IntSlider(minBoneCountRequired, 1, 100);
|
||
GUILayout.Label($"≥ {minBoneCountRequired}", GUILayout.Width(40));
|
||
}
|
||
EditorGUILayout.EndHorizontal();
|
||
|
||
if (useMinBoneCount)
|
||
{
|
||
EditorGUILayout.HelpBox("动画使用的骨骼数量必须达到最小值,防止单根骨骼动画被误判为适配", MessageType.None);
|
||
}
|
||
|
||
// 条件4:必要骨骼列表(强制)
|
||
EditorGUILayout.BeginHorizontal();
|
||
useRequiredBonesList = EditorGUILayout.Toggle(useRequiredBonesList, GUILayout.Width(20));
|
||
GUILayout.Label("强制条件B:必要骨骼列表", GUILayout.Width(150));
|
||
EditorGUILayout.EndHorizontal();
|
||
|
||
if (useRequiredBonesList)
|
||
{
|
||
EditorGUILayout.BeginVertical("box");
|
||
|
||
EditorGUILayout.HelpBox(
|
||
"⚠️ 强制条件:缺失任何必要骨骼,动画将直接标记为不适配!",
|
||
MessageType.Error);
|
||
|
||
GUILayout.BeginHorizontal();
|
||
newRequiredBone = EditorGUILayout.TextField("添加必要骨骼路径:", newRequiredBone);
|
||
if (GUILayout.Button("添加", GUILayout.Width(60)) && !string.IsNullOrEmpty(newRequiredBone))
|
||
{
|
||
if (!requiredBonesList.Contains(newRequiredBone))
|
||
requiredBonesList.Add(newRequiredBone);
|
||
newRequiredBone = "";
|
||
}
|
||
GUILayout.EndHorizontal();
|
||
|
||
// 快捷按钮行
|
||
GUILayout.BeginHorizontal();
|
||
if (GUILayout.Button("从当前骨骼列表选择", GUILayout.Width(150)))
|
||
{
|
||
ShowBoneSelectionDialog();
|
||
}
|
||
if (GUILayout.Button("添加根骨骼作为必要", GUILayout.Width(150)))
|
||
{
|
||
if (actualBoneFullPaths.Count > 0)
|
||
{
|
||
string rootBone = actualBoneFullPaths[0];
|
||
if (!requiredBonesList.Contains(rootBone))
|
||
requiredBonesList.Add(rootBone);
|
||
Debug.Log($"添加根骨骼: {rootBone}");
|
||
}
|
||
}
|
||
if (GUILayout.Button("清空列表", GUILayout.Width(100)))
|
||
{
|
||
requiredBonesList.Clear();
|
||
}
|
||
GUILayout.EndHorizontal();
|
||
|
||
if (requiredBonesList.Count > 0)
|
||
{
|
||
requiredBonesScrollPos = EditorGUILayout.BeginScrollView(requiredBonesScrollPos, GUILayout.Height(100));
|
||
for (int i = 0; i < requiredBonesList.Count; i++)
|
||
{
|
||
GUILayout.BeginHorizontal();
|
||
GUILayout.Label($"• {requiredBonesList[i]}", GUILayout.Width(350));
|
||
if (GUILayout.Button("删除", GUILayout.Width(50)))
|
||
{
|
||
requiredBonesList.RemoveAt(i);
|
||
i--;
|
||
}
|
||
GUILayout.EndHorizontal();
|
||
}
|
||
EditorGUILayout.EndScrollView();
|
||
}
|
||
|
||
EditorGUILayout.EndVertical();
|
||
}
|
||
|
||
GUILayout.Space(10);
|
||
|
||
// ========== 可选条件区域 ==========
|
||
GUILayout.Label("【可选条件】根据AND/OR逻辑判断", EditorStyles.boldLabel);
|
||
|
||
// 条件1:缺失数量
|
||
EditorGUILayout.BeginHorizontal();
|
||
useMismatchCount = EditorGUILayout.Toggle(useMismatchCount, GUILayout.Width(20));
|
||
GUILayout.Label("可选条件A:缺失骨骼数量", GUILayout.Width(130));
|
||
if (useMismatchCount)
|
||
{
|
||
mismatchCountThreshold = EditorGUILayout.IntSlider(mismatchCountThreshold, 0, 50);
|
||
GUILayout.Label($"< {mismatchCountThreshold}", GUILayout.Width(40));
|
||
}
|
||
EditorGUILayout.EndHorizontal();
|
||
|
||
// 条件2:匹配百分比
|
||
EditorGUILayout.BeginHorizontal();
|
||
useMatchPercentage = EditorGUILayout.Toggle(useMatchPercentage, GUILayout.Width(20));
|
||
GUILayout.Label("可选条件B:骨骼匹配百分比", GUILayout.Width(130));
|
||
if (useMatchPercentage)
|
||
{
|
||
matchPercentageRequired = EditorGUILayout.Slider(matchPercentageRequired, 0f, 100f);
|
||
GUILayout.Label($"≥ {matchPercentageRequired}%", GUILayout.Width(60));
|
||
}
|
||
EditorGUILayout.EndHorizontal();
|
||
|
||
EditorGUILayout.EndVertical();
|
||
}
|
||
|
||
private void ShowBoneSelectionDialog()
|
||
{
|
||
GenericMenu menu = new GenericMenu();
|
||
|
||
foreach (string bonePath in actualBoneFullPaths)
|
||
{
|
||
string path = bonePath;
|
||
string cleanPath = RemoveCommonPrefixes(path);
|
||
string displayName = $"{path}";
|
||
|
||
if (cleanPath != path.ToLower())
|
||
displayName += $" (匹配名: {cleanPath})";
|
||
|
||
menu.AddItem(new GUIContent(displayName), requiredBonesList.Contains(path), () => {
|
||
if (!requiredBonesList.Contains(path))
|
||
requiredBonesList.Add(path);
|
||
});
|
||
}
|
||
|
||
menu.ShowAsContext();
|
||
}
|
||
|
||
// -----------------------------
|
||
// 移除常见的前缀
|
||
// -----------------------------
|
||
private string RemoveCommonPrefixes(string path)
|
||
{
|
||
string[] prefixes = { "mixamorig:", "uma:", "character:", "armature|", "root|" };
|
||
string result = path.ToLower();
|
||
|
||
foreach (string prefix in prefixes)
|
||
{
|
||
if (result.StartsWith(prefix))
|
||
{
|
||
result = result.Substring(prefix.Length);
|
||
break;
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
// -----------------------------
|
||
// 从动画中提取骨骼路径(必须在主线程调用)
|
||
// -----------------------------
|
||
private List<string> GetAnimationBonePaths(AnimationClip clip)
|
||
{
|
||
if (animationBonesCache.ContainsKey(clip))
|
||
return animationBonesCache[clip];
|
||
|
||
HashSet<string> animationBones = new HashSet<string>();
|
||
|
||
var bindings = AnimationUtility.GetCurveBindings(clip);
|
||
|
||
foreach (var binding in bindings)
|
||
{
|
||
if (binding.type == typeof(Transform) || binding.type == typeof(Animator))
|
||
{
|
||
string path = binding.path;
|
||
if (string.IsNullOrEmpty(path)) continue;
|
||
animationBones.Add(path);
|
||
}
|
||
}
|
||
|
||
var objectBindings = AnimationUtility.GetObjectReferenceCurveBindings(clip);
|
||
foreach (var binding in objectBindings)
|
||
{
|
||
if (binding.type == typeof(Transform) || binding.type == typeof(Animator))
|
||
{
|
||
string path = binding.path;
|
||
if (string.IsNullOrEmpty(path)) continue;
|
||
animationBones.Add(path);
|
||
}
|
||
}
|
||
|
||
var result = animationBones.ToList();
|
||
animationBonesCache[clip] = result;
|
||
return result;
|
||
}
|
||
|
||
// -----------------------------
|
||
// 分析动画骨骼(完整修复版)
|
||
// -----------------------------
|
||
private (List<string> mismatchedBones, List<string> matchedBones, int matchedCount, int totalAnimBones, float matchPercentage)
|
||
AnalyzeAnimationBonesData(
|
||
List<string> animationBonePaths,
|
||
List<string> modelBoneFullPaths,
|
||
bool useFullPathMatchLocal)
|
||
{
|
||
List<string> mismatchedBones = new List<string>();
|
||
List<string> matchedBones = new List<string>();
|
||
|
||
// 模型没有骨骼
|
||
if (modelBoneFullPaths.Count == 0)
|
||
{
|
||
mismatchedBones.Add("错误:参考对象没有骨骼结构");
|
||
return (mismatchedBones, matchedBones, 0, 0, 0f);
|
||
}
|
||
|
||
// 动画没有骨骼
|
||
if (animationBonePaths.Count == 0)
|
||
{
|
||
mismatchedBones.Add("警告:动画中没有检测到任何骨骼曲线");
|
||
return (mismatchedBones, matchedBones, 0, 0, 0f);
|
||
}
|
||
|
||
int totalAnimBones = animationBonePaths.Count;
|
||
|
||
// 预处理模型骨骼路径
|
||
HashSet<string> modelPathSet = new HashSet<string>(modelBoneFullPaths.Select(p => p.ToLower()));
|
||
Dictionary<string, string> modelCleanMap = new Dictionary<string, string>();
|
||
|
||
foreach (string modelPath in modelBoneFullPaths)
|
||
{
|
||
modelCleanMap[modelPath.ToLower()] = RemoveCommonPrefixes(modelPath.ToLower());
|
||
}
|
||
|
||
bool[] matchResults = new bool[totalAnimBones];
|
||
|
||
// -----------------------------
|
||
// 并行匹配动画骨骼
|
||
// -----------------------------
|
||
Parallel.For(0, totalAnimBones, idx =>
|
||
{
|
||
string animPath = animationBonePaths[idx];
|
||
string lowerAnimPath = animPath.ToLower();
|
||
string cleanAnimPath = RemoveCommonPrefixes(lowerAnimPath);
|
||
|
||
bool found = false;
|
||
|
||
foreach (var kvp in modelCleanMap)
|
||
{
|
||
string modelFull = kvp.Key;
|
||
string modelClean = kvp.Value;
|
||
|
||
if (useFullPathMatchLocal)
|
||
{
|
||
// 完整路径匹配
|
||
if (lowerAnimPath == modelFull || cleanAnimPath == modelClean)
|
||
{
|
||
found = true;
|
||
break;
|
||
}
|
||
|
||
// 末尾匹配(防止前缀不同)
|
||
if (cleanAnimPath.EndsWith("/" + modelClean) ||
|
||
cleanAnimPath == modelClean ||
|
||
modelClean.EndsWith("/" + cleanAnimPath))
|
||
{
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// 名称匹配
|
||
string animName = lowerAnimPath.Split('/').Last();
|
||
string modelName = modelFull.Split('/').Last();
|
||
|
||
if (animName == modelName)
|
||
{
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
matchResults[idx] = found;
|
||
});
|
||
|
||
// -----------------------------
|
||
// 收集结果
|
||
// -----------------------------
|
||
for (int i = 0; i < totalAnimBones; i++)
|
||
{
|
||
if (matchResults[i])
|
||
matchedBones.Add(animationBonePaths[i]);
|
||
else
|
||
mismatchedBones.Add(animationBonePaths[i]);
|
||
}
|
||
|
||
int matchedCount = matchedBones.Count;
|
||
float matchPercentage = totalAnimBones > 0 ? (matchedCount * 100f / totalAnimBones) : 0f;
|
||
|
||
return (mismatchedBones, matchedBones, matchedCount, totalAnimBones, matchPercentage);
|
||
}
|
||
|
||
|
||
// -----------------------------
|
||
// 检查必要骨骼条件(纯数据计算)- 强制条件
|
||
// -----------------------------
|
||
private (bool passed, List<string> missingBones) CheckRequiredBonesConditionData(
|
||
List<string> animationBonePaths,
|
||
List<string> requiredBones,
|
||
bool useFullPathMatchLocal)
|
||
{
|
||
if (requiredBones.Count == 0)
|
||
return (true, new List<string>());
|
||
|
||
// 获取动画中的骨骼信息
|
||
HashSet<string> animPathSet = new HashSet<string>(animationBonePaths.Select(p => p.ToLower()));
|
||
HashSet<string> animCleanSet = new HashSet<string>(animationBonePaths.Select(p => RemoveCommonPrefixes(p)));
|
||
HashSet<string> animNameSet = new HashSet<string>(animationBonePaths.Select(p => p.Split('/').Last().ToLower()));
|
||
|
||
List<string> missingBones = new List<string>();
|
||
|
||
foreach (string required in requiredBones)
|
||
{
|
||
string lowerRequired = required.ToLower();
|
||
string cleanRequired = RemoveCommonPrefixes(lowerRequired);
|
||
string requiredName = lowerRequired.Split('/').Last();
|
||
|
||
bool found = false;
|
||
|
||
if (useFullPathMatchLocal)
|
||
{
|
||
// 1. 完整路径匹配
|
||
if (animPathSet.Contains(lowerRequired))
|
||
{
|
||
found = true;
|
||
}
|
||
// 2. 清理前缀后匹配
|
||
else if (animCleanSet.Contains(cleanRequired))
|
||
{
|
||
found = true;
|
||
}
|
||
// 3. 骨骼名称匹配
|
||
else if (animNameSet.Contains(requiredName))
|
||
{
|
||
found = true;
|
||
}
|
||
// 4. 后缀匹配
|
||
else
|
||
{
|
||
foreach (string animPath in animationBonePaths)
|
||
{
|
||
string cleanAnim = RemoveCommonPrefixes(animPath);
|
||
if (cleanAnim.EndsWith("/" + cleanRequired) || cleanAnim == cleanRequired)
|
||
{
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
found = animNameSet.Contains(requiredName);
|
||
}
|
||
|
||
if (!found)
|
||
{
|
||
missingBones.Add(required);
|
||
}
|
||
}
|
||
|
||
return (missingBones.Count == 0, missingBones);
|
||
}
|
||
|
||
// -----------------------------
|
||
// 动画适配判定(完整修复版)
|
||
// -----------------------------
|
||
private (bool isCompatible, string forceReason, string optionalReason) IsClipCompatibleData(
|
||
int mismatchCount,
|
||
int totalAnimBones,
|
||
float matchPercentage,
|
||
List<string> animPaths,
|
||
List<string> requiredBones,
|
||
bool useRequiredBonesList,
|
||
bool useMinBoneCount,
|
||
bool useMismatchCount,
|
||
bool useMatchPercentage,
|
||
int mismatchThreshold,
|
||
float matchPercentageRequired,
|
||
int minBoneCountRequired,
|
||
bool useAndLogic,
|
||
bool useFullPathMatch)
|
||
{
|
||
string forceReason = "";
|
||
string optionalReason = "";
|
||
|
||
// -----------------------------
|
||
// 1. 强制条件(不满足直接不适配)
|
||
// -----------------------------
|
||
|
||
// A. 最小骨骼数
|
||
if (useMinBoneCount)
|
||
{
|
||
if (totalAnimBones < minBoneCountRequired)
|
||
{
|
||
forceReason += $"[强制条件] 动画骨骼数 {totalAnimBones} < 最小要求 {minBoneCountRequired}\n";
|
||
return (false, forceReason, optionalReason);
|
||
}
|
||
}
|
||
|
||
// B. 必要骨骼列表
|
||
if (useRequiredBonesList && requiredBones.Count > 0)
|
||
{
|
||
foreach (var req in requiredBones)
|
||
{
|
||
bool found = false;
|
||
|
||
foreach (var animBone in animPaths)
|
||
{
|
||
if (useFullPathMatch)
|
||
{
|
||
// 完整路径匹配
|
||
if (animBone.ToLower() == req.ToLower())
|
||
{
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// 名称匹配
|
||
string animName = animBone.Split('/').Last().ToLower();
|
||
string reqName = req.Split('/').Last().ToLower();
|
||
if (animName == reqName)
|
||
{
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!found)
|
||
{
|
||
forceReason += $"[强制条件] 缺少必要骨骼:{req}\n";
|
||
return (false, forceReason, optionalReason);
|
||
}
|
||
}
|
||
}
|
||
|
||
// -----------------------------
|
||
// 2. 可选条件(根据 AND / OR 判断)
|
||
// -----------------------------
|
||
List<bool> optionalResults = new List<bool>();
|
||
|
||
// A. 缺失数量
|
||
if (useMismatchCount)
|
||
{
|
||
bool ok = mismatchCount < mismatchThreshold;
|
||
optionalResults.Add(ok);
|
||
|
||
if (!ok)
|
||
optionalReason += $"[可选条件] 缺失骨骼数 {mismatchCount} ≥ 阈值 {mismatchThreshold}\n";
|
||
}
|
||
|
||
// B. 匹配百分比
|
||
if (useMatchPercentage)
|
||
{
|
||
bool ok = matchPercentage >= matchPercentageRequired;
|
||
optionalResults.Add(ok);
|
||
|
||
if (!ok)
|
||
optionalReason += $"[可选条件] 匹配率 {matchPercentage:F1}% < 要求 {matchPercentageRequired}%\n";
|
||
}
|
||
|
||
// 如果没有启用任何可选条件 → 默认通过
|
||
if (optionalResults.Count == 0)
|
||
return (true, forceReason, optionalReason);
|
||
|
||
// AND 逻辑:全部满足才通过
|
||
if (useAndLogic)
|
||
{
|
||
bool allOK = optionalResults.All(r => r);
|
||
return (allOK, forceReason, optionalReason);
|
||
}
|
||
else
|
||
{
|
||
// OR 逻辑:任意满足即可
|
||
bool anyOK = optionalResults.Any(r => r);
|
||
return (anyOK, forceReason, optionalReason);
|
||
}
|
||
}
|
||
|
||
|
||
private string GetConditionDescription()
|
||
{
|
||
List<string> forcedConditions = new List<string>();
|
||
List<string> optionalConditions = new List<string>();
|
||
|
||
if (useMinBoneCount)
|
||
forcedConditions.Add($"最小骨骼 ≥ {minBoneCountRequired}");
|
||
if (useRequiredBonesList && requiredBonesList.Count > 0)
|
||
forcedConditions.Add($"必要骨骼({requiredBonesList.Count}个)");
|
||
|
||
if (useMismatchCount)
|
||
optionalConditions.Add($"缺失 < {mismatchCountThreshold}");
|
||
if (useMatchPercentage)
|
||
optionalConditions.Add($"匹配 ≥ {matchPercentageRequired}%");
|
||
|
||
string result = "";
|
||
if (forcedConditions.Count > 0)
|
||
result += $"【强制】{string.Join("、", forcedConditions)}";
|
||
|
||
if (optionalConditions.Count > 0)
|
||
{
|
||
if (result.Length > 0) result += " + ";
|
||
result += $"【可选】{string.Join(useAndLogic ? " 且 " : " 或 ", optionalConditions)}";
|
||
}
|
||
|
||
if (result.Length == 0)
|
||
return "无条件";
|
||
|
||
return result;
|
||
}
|
||
|
||
private void PreCacheAnimationBones()
|
||
{
|
||
cachedClipData.Clear();
|
||
|
||
foreach (var clip in clips)
|
||
{
|
||
if (clip == null) continue;
|
||
|
||
string path = AssetDatabase.GetAssetPath(clip);
|
||
string name = clip.name;
|
||
List<string> bones = GetAnimationBonePaths(clip); // 这里必须在主线程
|
||
|
||
cachedClipData.Add(new ClipData
|
||
{
|
||
clipPath = path,
|
||
clipName = name,
|
||
bonePaths = bones
|
||
});
|
||
}
|
||
}
|
||
|
||
|
||
// -----------------------------
|
||
// 异步执行检测
|
||
// -----------------------------
|
||
// 添加临时存储
|
||
private ConcurrentDictionary<string, (List<string> mismatched, int count, float percentage)> tempResults;
|
||
|
||
// ... 中间的代码保持不变 ...
|
||
|
||
// 异步检测入口(主线程)
|
||
private async void RunCheckAsync()
|
||
{
|
||
if (isProcessing || clips.Count == 0 || referenceObject == null)
|
||
{
|
||
EditorUtility.DisplayDialog("提示", "请先选择参考对象并添加动画!", "确定");
|
||
return;
|
||
}
|
||
|
||
// 确保缓存已构建(主线程中构建)
|
||
if (cachedClipData == null || cachedClipData.Count == 0)
|
||
{
|
||
PreCacheAnimationBones(); // 这里面要在主线程里填好 clipName / bonePaths
|
||
}
|
||
|
||
isProcessing = true;
|
||
hasRunCheck = false;
|
||
progress = 0f;
|
||
currentProgressText = "准备开始检测...";
|
||
Repaint();
|
||
|
||
mismatchDetails.Clear();
|
||
mismatchCountMap.Clear();
|
||
matchPercentageMap.Clear();
|
||
|
||
// 拷贝一份本地数据,避免并发修改
|
||
var clipDataListLocal = new List<ClipData>(cachedClipData);
|
||
var modelBones = new List<string>(actualBoneFullPaths);
|
||
var requiredBones = new List<string>(requiredBonesList);
|
||
|
||
// 把当前设置也拍一份,避免子线程访问 Editor 状态
|
||
bool useFullPathMatchLocal = useFullPathMatch;
|
||
bool useRequiredBonesListLocal = useRequiredBonesList;
|
||
bool useMinBoneCountLocal = useMinBoneCount;
|
||
bool useMismatchCountLocal = useMismatchCount;
|
||
bool useMatchPercentageLocal = useMatchPercentage;
|
||
int mismatchCountThresholdLocal = mismatchCountThreshold;
|
||
float matchPercentageRequiredLocal = matchPercentageRequired;
|
||
int minBoneCountRequiredLocal = minBoneCountRequired;
|
||
bool useAndLogicLocal = useAndLogic;
|
||
|
||
// 子线程执行纯数据计算
|
||
await Task.Run(() =>
|
||
{
|
||
RunCheckInternal(
|
||
clipDataListLocal,
|
||
modelBones,
|
||
requiredBones,
|
||
useFullPathMatchLocal,
|
||
useRequiredBonesListLocal,
|
||
useMinBoneCountLocal,
|
||
useMismatchCountLocal,
|
||
useMatchPercentageLocal,
|
||
mismatchCountThresholdLocal,
|
||
matchPercentageRequiredLocal,
|
||
minBoneCountRequiredLocal,
|
||
useAndLogicLocal
|
||
);
|
||
});
|
||
|
||
// 子线程结束后,把结果应用回主线程
|
||
if (tempResults != null)
|
||
{
|
||
foreach (var clip in clips)
|
||
{
|
||
string path = AssetDatabase.GetAssetPath(clip);
|
||
if (tempResults.TryGetValue(path, out var data))
|
||
{
|
||
var (mismatched, count, percentage) = data;
|
||
|
||
mismatchDetails[clip] = mismatched;
|
||
mismatchCountMap[clip] = count;
|
||
matchPercentageMap[clip] = percentage;
|
||
}
|
||
}
|
||
}
|
||
|
||
isProcessing = false;
|
||
hasRunCheck = true;
|
||
currentProgressText = "检测完成!";
|
||
progress = 1f;
|
||
Repaint();
|
||
|
||
EditorUtility.DisplayDialog("完成", "动画检测已完成!", "确定");
|
||
}
|
||
|
||
private void RunCheckInternal(
|
||
List<ClipData> clipDataListLocal,
|
||
List<string> modelBones,
|
||
List<string> requiredBones,
|
||
bool useFullPathMatchLocal,
|
||
bool useRequiredBonesListLocal,
|
||
bool useMinBoneCountLocal,
|
||
bool useMismatchCountLocal,
|
||
bool useMatchPercentageLocal,
|
||
int mismatchCountThresholdLocal,
|
||
float matchPercentageRequiredLocal,
|
||
int minBoneCountRequiredLocal,
|
||
bool useAndLogicLocal)
|
||
{
|
||
int total = clipDataListLocal.Count;
|
||
int badCount = 0;
|
||
int perfectCount = 0;
|
||
int goodCount = 0;
|
||
|
||
var results = new ConcurrentDictionary<string, (List<string> mismatched, int count, float percentage)>();
|
||
|
||
Parallel.For(0, total, i =>
|
||
{
|
||
var clipData = clipDataListLocal[i];
|
||
|
||
List<string> animationBones = clipData.bonePaths;
|
||
if (animationBones == null || animationBones.Count == 0)
|
||
return;
|
||
|
||
// 分析动画骨骼(纯数据)
|
||
var (mismatchedBones, matchedBones, matchedCount, totalAnimBones, matchPercentage) =
|
||
AnalyzeAnimationBonesData(animationBones, modelBones, useFullPathMatchLocal);
|
||
|
||
results[clipData.clipPath] = (mismatchedBones, mismatchedBones.Count, matchPercentage);
|
||
|
||
// 判定适配
|
||
var (isCompatible, forceReason, optionalReason) = IsClipCompatibleData(
|
||
mismatchedBones.Count,
|
||
totalAnimBones,
|
||
matchPercentage,
|
||
animationBones,
|
||
requiredBones,
|
||
useRequiredBonesListLocal,
|
||
useMinBoneCountLocal,
|
||
useMismatchCountLocal,
|
||
useMatchPercentageLocal,
|
||
mismatchCountThresholdLocal,
|
||
matchPercentageRequiredLocal,
|
||
minBoneCountRequiredLocal,
|
||
useAndLogicLocal,
|
||
useFullPathMatchLocal);
|
||
|
||
bool forcedFailed = !string.IsNullOrEmpty(forceReason);
|
||
string reason = string.IsNullOrEmpty(forceReason)
|
||
? optionalReason
|
||
: forceReason + optionalReason;
|
||
|
||
if (!isCompatible)
|
||
Interlocked.Increment(ref badCount);
|
||
else if (mismatchedBones.Count == 0 && matchPercentage >= 99.9f)
|
||
Interlocked.Increment(ref perfectCount);
|
||
else
|
||
Interlocked.Increment(ref goodCount);
|
||
|
||
string statusIcon = isCompatible ? "✓" : "✗";
|
||
string statusText;
|
||
if (!isCompatible && forcedFailed)
|
||
statusText = "不适配(强制条件)";
|
||
else if (!isCompatible)
|
||
statusText = "不适配(可选条件)";
|
||
else if (mismatchedBones.Count == 0 && matchPercentage >= 99.9f)
|
||
statusText = "完美适配";
|
||
else
|
||
statusText = "适配";
|
||
|
||
// 这里只输出纯文本日志,不访问 Unity 对象
|
||
lock (this)
|
||
{
|
||
Debug.Log($"[{statusIcon}] 动画 [{clipData.clipName}]");
|
||
Debug.Log($" ├─ 动画骨骼数: {totalAnimBones}");
|
||
Debug.Log($" ├─ 匹配骨骼: {matchedCount}/{totalAnimBones} ({matchPercentage:F1}%)");
|
||
Debug.Log($" ├─ 判定结果: {statusText}");
|
||
if (!string.IsNullOrEmpty(reason))
|
||
Debug.Log($" └─ 原因: {reason}");
|
||
|
||
if (mismatchedBones.Count > 0 && mismatchedBones.Count <= 5)
|
||
{
|
||
foreach (string bone in mismatchedBones)
|
||
Debug.Log($" └─ 不匹配: {bone}");
|
||
}
|
||
}
|
||
});
|
||
|
||
tempResults = results;
|
||
|
||
currentProgressText = $"检测完成! 完美:{perfectCount} 适配:{goodCount} 不适配:{badCount}";
|
||
}
|
||
|
||
|
||
|
||
private void DrawResultList()
|
||
{
|
||
if (!hasRunCheck)
|
||
{
|
||
EditorGUILayout.HelpBox("尚未检测,请先点击“开始检测动画”", MessageType.Info);
|
||
return;
|
||
}
|
||
|
||
if (clips.Count == 0)
|
||
{
|
||
EditorGUILayout.HelpBox("没有动画可显示", MessageType.Warning);
|
||
return;
|
||
}
|
||
|
||
// 先把所有结果算一遍,按“是否符合条件 + 匹配率”排序
|
||
var resultList = new List<(AnimationClip clip, int totalBones, int mismatch, int matched, float percent, bool isCompatible, bool forcedFailed, string reason)>();
|
||
|
||
foreach (var clip in clips)
|
||
{
|
||
if (!mismatchDetails.ContainsKey(clip))
|
||
continue;
|
||
|
||
// 动画骨骼路径 & 统计
|
||
List<string> animPaths = GetAnimationBonePaths(clip);
|
||
int totalAnimBones = animPaths.Count;
|
||
int mismatch = mismatchCountMap.TryGetValue(clip, out var mis) ? mis : 0;
|
||
float percentage = matchPercentageMap.TryGetValue(clip, out var p) ? p : 0f;
|
||
int matchedCount = Mathf.Max(0, totalAnimBones - mismatch);
|
||
|
||
// 用当前设置重新跑一遍判定逻辑(关键:这里会用到最小节点数 + 必要骨骼)
|
||
var (isCompatible, forceReason, optionalReason) = IsClipCompatibleData(
|
||
mismatch,
|
||
totalAnimBones,
|
||
percentage,
|
||
animPaths,
|
||
requiredBonesList,
|
||
useRequiredBonesList,
|
||
useMinBoneCount,
|
||
useMismatchCount,
|
||
useMatchPercentage,
|
||
mismatchCountThreshold,
|
||
matchPercentageRequired,
|
||
minBoneCountRequired,
|
||
useAndLogic,
|
||
useFullPathMatch
|
||
);
|
||
|
||
bool forcedFailed = !string.IsNullOrEmpty(forceReason);
|
||
string reason = string.IsNullOrEmpty(forceReason)
|
||
? optionalReason
|
||
: forceReason + optionalReason;
|
||
|
||
resultList.Add((clip, totalAnimBones, mismatch, matchedCount, percentage, isCompatible, forcedFailed, reason));
|
||
}
|
||
|
||
// 排序:先符合条件的,再不符合的;同类里按匹配率从高到低
|
||
resultList = resultList
|
||
.OrderByDescending(r => r.isCompatible)
|
||
.ThenByDescending(r => r.percent)
|
||
.ToList();
|
||
|
||
// 开始画 UI
|
||
resultScrollPos = EditorGUILayout.BeginScrollView(resultScrollPos, GUILayout.Height(260));
|
||
|
||
// 表头
|
||
EditorGUILayout.BeginHorizontal();
|
||
GUILayout.Label("状态", GUILayout.Width(40));
|
||
GUILayout.Label("动画名", GUILayout.Width(200));
|
||
GUILayout.Label("总骨骼", GUILayout.Width(60));
|
||
GUILayout.Label("匹配数", GUILayout.Width(70));
|
||
GUILayout.Label("缺失数", GUILayout.Width(60));
|
||
GUILayout.Label("匹配率", GUILayout.Width(70));
|
||
GUILayout.Label("判定结果", GUILayout.Width(120));
|
||
EditorGUILayout.EndHorizontal();
|
||
|
||
GUILayout.Space(4);
|
||
|
||
foreach (var r in resultList)
|
||
{
|
||
var clip = r.clip;
|
||
|
||
// 状态 & 文案
|
||
string statusIcon;
|
||
string statusText;
|
||
Color rowColor;
|
||
|
||
if (!r.isCompatible && r.forcedFailed)
|
||
{
|
||
statusIcon = "✗";
|
||
statusText = "不适配(强制条件)";
|
||
rowColor = new Color(1f, 0.8f, 0.8f);
|
||
}
|
||
else if (!r.isCompatible)
|
||
{
|
||
statusIcon = "✗";
|
||
statusText = "不适配(可选条件)";
|
||
rowColor = new Color(1f, 0.9f, 0.8f);
|
||
}
|
||
else if (r.mismatch == 0 && r.percent >= 99.9f)
|
||
{
|
||
statusIcon = "✓";
|
||
statusText = "完美适配";
|
||
rowColor = new Color(0.8f, 1f, 0.8f);
|
||
}
|
||
else
|
||
{
|
||
statusIcon = "•";
|
||
statusText = "适配";
|
||
rowColor = new Color(1f, 1f, 0.9f);
|
||
}
|
||
|
||
// 横向一行平铺
|
||
var oldColor = GUI.backgroundColor;
|
||
GUI.backgroundColor = rowColor;
|
||
EditorGUILayout.BeginHorizontal("box");
|
||
GUI.backgroundColor = oldColor;
|
||
|
||
GUILayout.Label(statusIcon, GUILayout.Width(40));
|
||
GUILayout.Label(clip.name, GUILayout.Width(200));
|
||
GUILayout.Label(r.totalBones.ToString(), GUILayout.Width(60));
|
||
GUILayout.Label(r.matched.ToString(), GUILayout.Width(70));
|
||
GUILayout.Label(r.mismatch.ToString(), GUILayout.Width(60));
|
||
GUILayout.Label($"{r.percent:F1}%", GUILayout.Width(70));
|
||
GUILayout.Label(statusText, GUILayout.Width(120));
|
||
|
||
// 详情按钮:展开缺失骨骼 + 原因
|
||
if (GUILayout.Button("详情", GUILayout.Width(50)))
|
||
{
|
||
var msg = $"动画:{clip.name}\n" +
|
||
$"总骨骼:{r.totalBones}\n" +
|
||
$"匹配骨骼:{r.matched}\n" +
|
||
$"缺失骨骼:{r.mismatch}\n" +
|
||
$"匹配率:{r.percent:F1}%\n\n";
|
||
|
||
if (!string.IsNullOrEmpty(r.reason))
|
||
msg += $"判定原因:\n{r.reason}\n\n";
|
||
|
||
if (mismatchDetails.TryGetValue(clip, out var list) && list.Count > 0)
|
||
{
|
||
msg += "缺失骨骼列表(最多显示 50 个):\n";
|
||
foreach (var bone in list.Take(50))
|
||
msg += $" • {bone}\n";
|
||
|
||
if (list.Count > 50)
|
||
msg += $" ... 还有 {list.Count - 50} 个未显示\n";
|
||
}
|
||
|
||
EditorUtility.DisplayDialog("检测详情", msg, "确定");
|
||
}
|
||
|
||
EditorGUILayout.EndHorizontal();
|
||
GUILayout.Space(2);
|
||
}
|
||
|
||
EditorGUILayout.EndScrollView();
|
||
}
|
||
|
||
|
||
|
||
|
||
private void ShowDetailsPopup(AnimationClip clip, List<string> mismatchedBones, float percentage, int matchedCount, int totalAnimBones, bool isCompatible, string reason, bool forcedFailed)
|
||
{
|
||
string message = $"━━━━━━━━━━━━━━━━━━━━\n";
|
||
message += $"🎬 动画: {clip.name}\n";
|
||
message += $"━━━━━━━━━━━━━━━━━━━━\n\n";
|
||
|
||
message += $"📦 参考对象: {referenceObject.name}\n";
|
||
message += $"🦴 参考骨骼数: {actualBoneFullPaths.Count}\n";
|
||
message += $"🔍 匹配模式: {(useFullPathMatch ? "完整路径匹配" : "仅名称匹配")}\n\n";
|
||
|
||
message += $"━━━━━━━━━━━━━━━━━━━━\n";
|
||
message += $"📊 检测结果:\n";
|
||
message += $" 动画使用骨骼: {totalAnimBones} 个\n";
|
||
message += $" 匹配骨骼: {matchedCount} 个\n";
|
||
message += $" 匹配度: {percentage:F1}%\n\n";
|
||
|
||
message += $"━━━━━━━━━━━━━━━━━━━━\n";
|
||
message += $"⚙️ 判定条件:\n";
|
||
message += $" {GetConditionDescription()}\n";
|
||
|
||
// 显示各条件状态
|
||
List<string> animPaths = GetAnimationBonePaths(clip);
|
||
|
||
// 强制条件状态
|
||
message += $"\n【强制条件】\n";
|
||
if (useMinBoneCount)
|
||
{
|
||
bool minOk = totalAnimBones >= minBoneCountRequired;
|
||
message += $" 最小骨骼数({minBoneCountRequired}): {(minOk ? "✓ " + totalAnimBones : "✗ " + totalAnimBones)}\n";
|
||
}
|
||
|
||
if (useRequiredBonesList && requiredBonesList.Count > 0)
|
||
{
|
||
var (requiredOk, missing) = CheckRequiredBonesConditionData(animPaths, requiredBonesList, useFullPathMatch);
|
||
message += $" 必要骨骼: {(requiredOk ? "✓ 满足" : "✗ 不满足")}\n";
|
||
if (!requiredOk && missing.Count > 0)
|
||
{
|
||
foreach (string m in missing)
|
||
{
|
||
message += $" • 缺失: {m}\n";
|
||
}
|
||
}
|
||
}
|
||
|
||
// 可选条件状态
|
||
message += $"\n【可选条件】\n";
|
||
if (useMismatchCount)
|
||
{
|
||
bool mismatchOk = mismatchedBones.Count < mismatchCountThreshold;
|
||
message += $" 缺失数量({mismatchedBones.Count}): {(mismatchOk ? "✓ <" + mismatchCountThreshold : "✗ ≥" + mismatchCountThreshold)}\n";
|
||
}
|
||
|
||
if (useMatchPercentage)
|
||
{
|
||
bool percentOk = percentage >= matchPercentageRequired;
|
||
message += $" 匹配百分比({percentage:F1}%): {(percentOk ? "✓ ≥" + matchPercentageRequired : "✗ <" + matchPercentageRequired)}\n";
|
||
}
|
||
|
||
message += $"\n━━━━━━━━━━━━━━━━━━━━\n";
|
||
message += $"📋 最终判定: {(isCompatible ? "✓ 适配" : "✗ 不适配")}\n";
|
||
|
||
if (!isCompatible && !string.IsNullOrEmpty(reason))
|
||
{
|
||
message += $"📌 原因: {reason}\n";
|
||
}
|
||
|
||
message += $"\n━━━━━━━━━━━━━━━━━━━━\n";
|
||
|
||
if (mismatchedBones != null && mismatchedBones.Count > 0)
|
||
{
|
||
var realMissing = mismatchedBones.Where(b => !b.Contains("警告") && !b.Contains("请先")).ToList();
|
||
|
||
if (realMissing.Count > 0)
|
||
{
|
||
message += $"\n⚠️ 不匹配的骨骼路径 ({realMissing.Count} 个):\n\n";
|
||
message += string.Join("\n", realMissing.Select(b => "• " + b));
|
||
}
|
||
}
|
||
else
|
||
{
|
||
message += $"\n✓ 所有动画骨骼路径都匹配!";
|
||
}
|
||
|
||
EditorUtility.DisplayDialog("骨骼匹配详情", message, "确定");
|
||
}
|
||
|
||
|
||
private void ExportResults()
|
||
{
|
||
if (!hasRunCheck)
|
||
{
|
||
EditorUtility.DisplayDialog("提示", "请先执行检测!", "确定");
|
||
return;
|
||
}
|
||
|
||
string root = rootFolder;
|
||
if (!Directory.Exists(root))
|
||
Directory.CreateDirectory(root);
|
||
|
||
string goodPath = Path.Combine(root, goodSubFolder);
|
||
string badPath = Path.Combine(root, badSubFolder);
|
||
|
||
if (!Directory.Exists(goodPath))
|
||
Directory.CreateDirectory(goodPath);
|
||
|
||
if (exportBadClips && !Directory.Exists(badPath))
|
||
Directory.CreateDirectory(badPath);
|
||
|
||
int goodCount = 0;
|
||
int badCount = 0;
|
||
|
||
foreach (var clip in clips)
|
||
{
|
||
if (!mismatchDetails.ContainsKey(clip))
|
||
continue;
|
||
|
||
List<string> animPaths = GetAnimationBonePaths(clip);
|
||
int totalAnimBones = animPaths.Count;
|
||
int mismatch = mismatchCountMap[clip];
|
||
float percentage = matchPercentageMap[clip];
|
||
|
||
// 使用最终判定逻辑
|
||
var (isCompatible, forceReason, optionalReason) = IsClipCompatibleData(
|
||
mismatch,
|
||
totalAnimBones,
|
||
percentage,
|
||
animPaths,
|
||
requiredBonesList,
|
||
useRequiredBonesList,
|
||
useMinBoneCount,
|
||
useMismatchCount,
|
||
useMatchPercentage,
|
||
mismatchCountThreshold,
|
||
matchPercentageRequired,
|
||
minBoneCountRequired,
|
||
useAndLogic,
|
||
useFullPathMatch
|
||
);
|
||
|
||
string src = AssetDatabase.GetAssetPath(clip);
|
||
string dst = Path.Combine(isCompatible ? goodPath : badPath, clip.name + ".anim");
|
||
|
||
if (isCompatible)
|
||
{
|
||
File.Copy(src, dst, true);
|
||
goodCount++;
|
||
}
|
||
else if (exportBadClips)
|
||
{
|
||
File.Copy(src, dst, true);
|
||
badCount++;
|
||
}
|
||
}
|
||
|
||
AssetDatabase.Refresh();
|
||
|
||
EditorUtility.DisplayDialog(
|
||
"导出完成",
|
||
$"适配动画:{goodCount}\n不适配动画:{badCount}",
|
||
"确定"
|
||
);
|
||
}
|
||
|
||
} |