using UnityEditor; using UnityEngine; using UnityEngine.UIElements; using UnityEditor.UIElements; namespace KindRetargeting { [CustomEditor(typeof(CustomRetargetingScript))] public class CustomRetargetingScriptEditor : BaseRetargetingEditor { private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss"; // SerializedProperty private SerializedProperty sourceAnimatorProp; private SerializedProperty targetAnimatorProp; private SerializedProperty hipsOffsetXProp; private SerializedProperty hipsOffsetYProp; private SerializedProperty hipsOffsetZProp; private SerializedProperty debugAxisNormalizerProp; private SerializedProperty fingerCopyModeProp; private SerializedProperty useMotionFilterProp; private SerializedProperty filterBufferSizeProp; private SerializedProperty useBodyRoughMotionProp; private SerializedProperty useFingerRoughMotionProp; private SerializedProperty bodyRoughnessProp; private SerializedProperty fingerRoughnessProp; private SerializedProperty kneeInOutWeightProp; private SerializedProperty kneeFrontBackWeightProp; private SerializedProperty footFrontBackOffsetProp; private SerializedProperty footInOutOffsetProp; private SerializedProperty floorHeightProp; private SerializedProperty avatarScaleProp; // Dynamic UI private VisualElement calibrationContainer; private Label cacheStatusLabel; protected override void OnEnable() { base.OnEnable(); sourceAnimatorProp = serializedObject.FindProperty("sourceAnimator"); targetAnimatorProp = serializedObject.FindProperty("targetAnimator"); hipsOffsetXProp = serializedObject.FindProperty("hipsOffsetX"); hipsOffsetYProp = serializedObject.FindProperty("hipsOffsetY"); hipsOffsetZProp = serializedObject.FindProperty("hipsOffsetZ"); debugAxisNormalizerProp = serializedObject.FindProperty("debugAxisNormalizer"); fingerCopyModeProp = serializedObject.FindProperty("fingerCopyMode"); useMotionFilterProp = serializedObject.FindProperty("useMotionFilter"); filterBufferSizeProp = serializedObject.FindProperty("filterBufferSize"); useBodyRoughMotionProp = serializedObject.FindProperty("useBodyRoughMotion"); useFingerRoughMotionProp = serializedObject.FindProperty("useFingerRoughMotion"); bodyRoughnessProp = serializedObject.FindProperty("bodyRoughness"); fingerRoughnessProp = serializedObject.FindProperty("fingerRoughness"); kneeInOutWeightProp = serializedObject.FindProperty("kneeInOutWeight"); kneeFrontBackWeightProp = serializedObject.FindProperty("kneeFrontBackWeight"); footFrontBackOffsetProp = serializedObject.FindProperty("footFrontBackOffset"); footInOutOffsetProp = serializedObject.FindProperty("footInOutOffset"); floorHeightProp = serializedObject.FindProperty("floorHeight"); avatarScaleProp = serializedObject.FindProperty("avatarScale"); } public override VisualElement CreateInspectorGUI() { var root = new VisualElement(); var commonUss = AssetDatabase.LoadAssetAtPath(CommonUssPath); if (commonUss != null) root.styleSheets.Add(commonUss); // 원본 Animator root.Add(new PropertyField(sourceAnimatorProp, "원본 Animator")); // 아바타 크기 설정 var scaleFoldout = new Foldout { text = "아바타 크기 설정", value = true }; scaleFoldout.Add(new PropertyField(avatarScaleProp, "아바타 크기")); root.Add(scaleFoldout); // 힙 위치 보정 root.Add(BuildHipsSection()); // 무릎 위치 조정 var kneeFoldout = new Foldout { text = "무릎 위치 조정", value = true }; kneeFoldout.Add(new Slider("무릎 앞/뒤 가중치", -1f, 1f) { showInputField = true, tooltip = "음수: 뒤로, 양수: 앞으로" }); kneeFoldout.Q().BindProperty(kneeFrontBackWeightProp); var kneeInOut = new Slider("무릎 안/밖 가중치", -1f, 1f) { showInputField = true, tooltip = "음수: 안쪽, 양수: 바깥쪽" }; kneeInOut.BindProperty(kneeInOutWeightProp); kneeFoldout.Add(kneeInOut); root.Add(kneeFoldout); // 발 IK 위치 조정 var footFoldout = new Foldout { text = "발 IK 위치 조정", value = true }; var footFB = new Slider("발 앞/뒤 오프셋", -1f, 1f) { showInputField = true, tooltip = "+: 앞으로, -: 뒤로" }; footFB.BindProperty(footFrontBackOffsetProp); footFoldout.Add(footFB); var footIO = new Slider("발 벌리기/모으기", -1f, 1f) { showInputField = true, tooltip = "+: 벌리기, -: 모으기" }; footIO.BindProperty(footInOutOffsetProp); footFoldout.Add(footIO); root.Add(footFoldout); // 손가락 복제 설정 root.Add(BuildFingerCopySection()); // 모션 필터링 설정 root.Add(BuildMotionFilterSection()); // 러프 모션 설정 root.Add(BuildRoughMotionSection()); // 바닥 높이 조정 var floorFoldout = new Foldout { text = "바닥 높이 조정", value = false }; floorFoldout.Add(new PropertyField(floorHeightProp, "바닥 높이 (-1 ~ 1)")); root.Add(floorFoldout); // 캐시 상태 + 캘리브레이션 버튼 root.Add(BuildCacheSection()); // 변경 시 저장 root.TrackSerializedObjectValue(serializedObject, so => { if (target == null) return; var script = (CustomRetargetingScript)target; if (script.targetAnimator != null) script.SaveSettings(); }); return root; } private VisualElement BuildHipsSection() { var foldout = new Foldout { text = "힙 위치 보정 (로컬 좌표계)" }; // 축 매핑 정보 var axisInfo = new HelpBox("플레이 모드에서 T-포즈 분석 후 축 매핑 정보가 표시됩니다.\n이 매핑은 각 아바타의 힙 로컬 축 방향에 맞춰 자동 계산됩니다.", HelpBoxMessageType.Info); foldout.Add(axisInfo); // 주기적으로 축 매핑 정보 갱신 foldout.schedule.Execute(() => { if (target == null || debugAxisNormalizerProp == null) return; serializedObject.Update(); Vector3 axisMapping = debugAxisNormalizerProp.vector3Value; if (Application.isPlaying && axisMapping != Vector3.one) { string GetAxisName(float value) { int axis = Mathf.RoundToInt(Mathf.Abs(value)); string sign = value > 0 ? "+" : "-"; return axis switch { 1 => $"{sign}X", 2 => $"{sign}Y", 3 => $"{sign}Z", _ => "?" }; } axisInfo.text = "T-포즈에서 분석된 축 매핑:\n" + $" 좌우 오프셋 → 로컬 {GetAxisName(axisMapping.x)} 축\n" + $" 상하 오프셋 → 로컬 {GetAxisName(axisMapping.y)} 축\n" + $" 앞뒤 오프셋 → 로컬 {GetAxisName(axisMapping.z)} 축\n\n" + "이 매핑 덕분에 모든 아바타에서 동일하게 작동합니다."; } else { axisInfo.text = "플레이 모드에서 T-포즈 분석 후 축 매핑 정보가 표시됩니다.\n이 매핑은 각 아바타의 힙 로컬 축 방향에 맞춰 자동 계산됩니다."; } }).Every(500); foldout.Add(new PropertyField(hipsOffsetXProp, "좌우 오프셋 (←-/+→)") { tooltip = "캐릭터 기준 왼쪽(-) / 오른쪽(+)" }); foldout.Add(new PropertyField(hipsOffsetYProp, "상하 오프셋 (↓-/+↑)") { tooltip = "캐릭터 기준 아래(-) / 위(+)" }); foldout.Add(new PropertyField(hipsOffsetZProp, "앞뒤 오프셋 (←-/+→)") { tooltip = "캐릭터 기준 뒤(-) / 앞(+)" }); foldout.Add(new HelpBox("로컬 좌표계 기반: 캐릭터의 회전 상태와 관계없이 항상 캐릭터 기준으로 이동합니다.", HelpBoxMessageType.Info)); return foldout; } private VisualElement BuildFingerCopySection() { var foldout = new Foldout { text = "손가락 복제 설정" }; foldout.Add(new PropertyField(fingerCopyModeProp, "복제 방식") { tooltip = "손가락 포즈를 복제하는 방식을 선택합니다." }); // Mingle 모드 캘리브레이션 컨테이너 calibrationContainer = new VisualElement(); calibrationContainer.style.backgroundColor = new Color(0, 0, 0, 0.1f); calibrationContainer.style.borderTopLeftRadius = calibrationContainer.style.borderTopRightRadius = calibrationContainer.style.borderBottomLeftRadius = calibrationContainer.style.borderBottomRightRadius = 4; calibrationContainer.style.paddingTop = calibrationContainer.style.paddingBottom = calibrationContainer.style.paddingLeft = calibrationContainer.style.paddingRight = 6; calibrationContainer.style.marginTop = 4; calibrationContainer.Add(new Label("Mingle 캘리브레이션") { style = { unityFontStyleAndWeight = FontStyle.Bold } }); calibrationContainer.Add(new HelpBox( "Mingle 모드는 소스 아바타의 손가락 회전 범위를 캘리브레이션하여 타겟에 적용합니다.\n" + "1. 손가락을 완전히 펼친 상태에서 '펼침 기록' 클릭\n" + "2. 손가락을 완전히 모은(주먹) 상태에서 '모음 기록' 클릭", HelpBoxMessageType.Info)); // 수동 캘리브레이션 버튼 var manualRow = new VisualElement { style = { flexDirection = FlexDirection.Row, marginTop = 4 } }; var openBtn = new Button(() => ((CustomRetargetingScript)target).CalibrateMingleOpen()) { text = "펼침 기록 (Open)" }; openBtn.style.flexGrow = 1; openBtn.style.marginRight = 2; manualRow.Add(openBtn); var closeBtn = new Button(() => ((CustomRetargetingScript)target).CalibrateMingleClose()) { text = "모음 기록 (Close)" }; closeBtn.style.flexGrow = 1; manualRow.Add(closeBtn); calibrationContainer.Add(manualRow); // 자동 캘리브레이션 버튼 var autoBtn = new Button(() => ((CustomRetargetingScript)target).StartAutoCalibration()) { text = "자동 캘리브레이션 (3초 펼침 → 3초 모음)" }; autoBtn.style.marginTop = 4; calibrationContainer.Add(autoBtn); // 플레이 모드 경고 var playWarning = new HelpBox("캘리브레이션은 플레이 모드에서만 가능합니다.", HelpBoxMessageType.Warning); calibrationContainer.Add(playWarning); // 자동 캘리브레이션 진행 상태 var autoCalibStatus = new VisualElement(); autoCalibStatus.style.backgroundColor = new Color(0, 0, 0, 0.15f); autoCalibStatus.style.borderTopLeftRadius = autoCalibStatus.style.borderTopRightRadius = autoCalibStatus.style.borderBottomLeftRadius = autoCalibStatus.style.borderBottomRightRadius = 4; autoCalibStatus.style.paddingTop = autoCalibStatus.style.paddingBottom = autoCalibStatus.style.paddingLeft = autoCalibStatus.style.paddingRight = 4; autoCalibStatus.style.marginTop = 4; var statusLabel = new Label("자동 캘리브레이션 진행 중"); statusLabel.style.unityFontStyleAndWeight = FontStyle.Bold; autoCalibStatus.Add(statusLabel); var statusDetailLabel = new Label(); autoCalibStatus.Add(statusDetailLabel); var timeLabel = new Label(); autoCalibStatus.Add(timeLabel); var cancelBtn = new Button(() => ((CustomRetargetingScript)target).StopAutoCalibration()) { text = "취소" }; autoCalibStatus.Add(cancelBtn); calibrationContainer.Add(autoCalibStatus); foldout.Add(calibrationContainer); // 주기적으로 Mingle 모드 표시/숨김 + 캘리브레이션 상태 갱신 foldout.schedule.Execute(() => { if (target == null) return; serializedObject.Update(); bool isMingle = fingerCopyModeProp.enumValueIndex == (int)EnumsList.FingerCopyMode.Mingle; calibrationContainer.style.display = isMingle ? DisplayStyle.Flex : DisplayStyle.None; if (isMingle) { bool isPlaying = Application.isPlaying; var script = (CustomRetargetingScript)target; bool isAutoCalib = isPlaying && script.IsAutoCalibrating; openBtn.SetEnabled(isPlaying && !isAutoCalib); closeBtn.SetEnabled(isPlaying && !isAutoCalib); autoBtn.SetEnabled(isPlaying && !isAutoCalib); playWarning.style.display = isPlaying ? DisplayStyle.None : DisplayStyle.Flex; manualRow.style.display = isAutoCalib ? DisplayStyle.None : DisplayStyle.Flex; autoBtn.style.display = isAutoCalib ? DisplayStyle.None : DisplayStyle.Flex; autoCalibStatus.style.display = isAutoCalib ? DisplayStyle.Flex : DisplayStyle.None; if (isAutoCalib) { statusDetailLabel.text = $"상태: {script.AutoCalibrationStatus}"; timeLabel.text = $"남은 시간: {script.AutoCalibrationTimeRemaining:F1}초"; } } }).Every(200); // 초기 상태 bool initMingle = fingerCopyModeProp.enumValueIndex == (int)EnumsList.FingerCopyMode.Mingle; calibrationContainer.style.display = initMingle ? DisplayStyle.Flex : DisplayStyle.None; autoCalibStatus.style.display = DisplayStyle.None; return foldout; } private VisualElement BuildMotionFilterSection() { var foldout = new Foldout { text = "모션 필터링 설정" }; foldout.Add(new PropertyField(useMotionFilterProp, "모션 필터 사용") { tooltip = "모션 필터링을 적용할지 여부를 설정합니다." }); var bufferField = new PropertyField(filterBufferSizeProp, "필터 버퍼 크기") { tooltip = "모션 필터링에 사용할 버퍼의 크기를 설정합니다. (2-10)" }; foldout.Add(bufferField); foldout.TrackPropertyValue(useMotionFilterProp, prop => { bufferField.style.display = prop.boolValue ? DisplayStyle.Flex : DisplayStyle.None; }); bufferField.style.display = useMotionFilterProp.boolValue ? DisplayStyle.Flex : DisplayStyle.None; return foldout; } private VisualElement BuildRoughMotionSection() { var foldout = new Foldout { text = "러프 모션 설정" }; // 몸 foldout.Add(new PropertyField(useBodyRoughMotionProp, "몸 러프 모션 사용") { tooltip = "몸의 러프한 움직임을 적용할지 여부를 설정합니다." }); var bodyRoughField = new PropertyField(bodyRoughnessProp, "몸 러프니스") { tooltip = "몸 전체의 러프한 정도 (0: 없음, 1: 최대)" }; foldout.Add(bodyRoughField); foldout.TrackPropertyValue(useBodyRoughMotionProp, prop => { bodyRoughField.style.display = prop.boolValue ? DisplayStyle.Flex : DisplayStyle.None; }); bodyRoughField.style.display = useBodyRoughMotionProp.boolValue ? DisplayStyle.Flex : DisplayStyle.None; // 손가락 foldout.Add(new PropertyField(useFingerRoughMotionProp, "손가락 러프 모션 사용") { tooltip = "손가락의 러프한 움직임을 적용할지 여부를 설정합니다." }); var fingerRoughField = new PropertyField(fingerRoughnessProp, "손가락 러프니스") { tooltip = "손가락의 러프한 정도 (0: 없음, 1: 최대)" }; foldout.Add(fingerRoughField); foldout.TrackPropertyValue(useFingerRoughMotionProp, prop => { fingerRoughField.style.display = prop.boolValue ? DisplayStyle.Flex : DisplayStyle.None; }); fingerRoughField.style.display = useFingerRoughMotionProp.boolValue ? DisplayStyle.Flex : DisplayStyle.None; return foldout; } private VisualElement BuildCacheSection() { var container = new VisualElement { style = { marginTop = 8 } }; var script = (CustomRetargetingScript)target; bool hasCached = script.HasCachedSettings(); cacheStatusLabel = new Label(hasCached ? "캘리브레이션 데이터가 저장되어 있습니다." : "저장된 캘리브레이션 데이터가 없습니다."); cacheStatusLabel.style.unityFontStyleAndWeight = FontStyle.Bold; container.Add(cacheStatusLabel); var btnRow = new VisualElement { style = { flexDirection = FlexDirection.Row, marginTop = 4 } }; var calibBtn = new Button(() => { ((CustomRetargetingScript)target).I_PoseCalibration(); UpdateCacheStatus(); }) { text = "I-포즈 캘리브레이션" }; calibBtn.style.flexGrow = 1; calibBtn.style.marginRight = 2; btnRow.Add(calibBtn); var deleteCacheBtn = new Button(() => { ((CustomRetargetingScript)target).ResetPoseAndCache(); UpdateCacheStatus(); }) { text = "캐시 데이터 삭제" }; deleteCacheBtn.style.flexGrow = 1; btnRow.Add(deleteCacheBtn); container.Add(btnRow); // 주기적으로 캐시 상태 갱신 container.schedule.Execute(() => { if (target == null) return; bool cached = ((CustomRetargetingScript)target).HasCachedSettings(); deleteCacheBtn.style.display = cached ? DisplayStyle.Flex : DisplayStyle.None; cacheStatusLabel.text = cached ? "캘리브레이션 데이터가 저장되어 있습니다." : "저장된 캘리브레이션 데이터가 없습니다."; }).Every(1000); deleteCacheBtn.style.display = hasCached ? DisplayStyle.Flex : DisplayStyle.None; return container; } private void UpdateCacheStatus() { if (cacheStatusLabel == null || target == null) return; bool hasCached = ((CustomRetargetingScript)target).HasCachedSettings(); cacheStatusLabel.text = hasCached ? "캘리브레이션 데이터가 저장되어 있습니다." : "저장된 캘리브레이션 데이터가 없습니다."; } } }