using System; using System.Collections; using System.Collections.Generic; using System.Reflection; using UnityEngine; using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace KindRetargeting.Remote { /// /// 리타게팅 원격 제어 컨트롤러 /// WebSocket 서버를 관리하고 메시지를 처리합니다. /// (HTTP UI는 Streamingle Dashboard에 통합됨) /// public class RetargetingRemoteController : MonoBehaviour { [Header("서버 설정")] [SerializeField] private int wsPort = 64212; [SerializeField] private bool autoStart = true; [Header("캐릭터 등록")] [SerializeField] private List registeredCharacters = new List(); private RetargetingWebSocketServer wsServer; private Queue mainThreadActions = new Queue(); public bool IsRunning => wsServer?.IsRunning == true; public int WsPort { get => wsPort; set => wsPort = value; } public bool AutoStart { get => autoStart; set => autoStart = value; } // 손 포즈 프리셋 목록 private static readonly string[] handPosePresets = new string[] { "가위", "바위", "보", "브이", "검지", "초기화" }; private void Start() { if (autoStart) { StartServer(); } } private void OnDestroy() { StopServer(); } private void Update() { // 메인 스레드 액션 처리 lock (mainThreadActions) { while (mainThreadActions.Count > 0) { var action = mainThreadActions.Dequeue(); try { action?.Invoke(); } catch (Exception ex) { Debug.LogError($"[RetargetingRemote] 메인 스레드 액션 오류: {ex.Message}"); } } } } public void StartServer() { if (IsRunning) return; wsServer = new RetargetingWebSocketServer(wsPort); wsServer.OnMessageReceived += OnWebSocketMessage; wsServer.Start(); Debug.Log($"[RetargetingRemote] WebSocket 서버 시작됨 - WS: {wsPort}"); } public void StopServer() { wsServer?.Stop(); if (wsServer != null) { wsServer.OnMessageReceived -= OnWebSocketMessage; } Debug.Log("[RetargetingRemote] 서버 중지됨"); } private void OnWebSocketMessage(string message) { EnqueueMainThread(() => ProcessMessage(message)); } private void EnqueueMainThread(Action action) { lock (mainThreadActions) { mainThreadActions.Enqueue(action); } } private void ProcessMessage(string message) { try { var json = JObject.Parse(message); string action = json["action"]?.ToString(); switch (action) { case "refresh": SendCharacterList(); SendHandPosePresets(); break; case "getCharacterData": { string charId = json["characterId"]?.ToString(); SendCharacterData(charId); } break; case "updateValueRealtime": { string charId = json["characterId"]?.ToString(); string property = json["property"]?.ToString(); float value = json["value"]?.Value() ?? 0f; UpdateValue(charId, property, value); BroadcastValueChanged(charId, property, value); } break; case "setHandPosePreset": { string charId = json["characterId"]?.ToString(); string property = json["property"]?.ToString(); string presetName = json["stringValue"]?.ToString(); ApplyHandPosePreset(charId, property, presetName); } break; case "calibrateIPose": { string charId = json["characterId"]?.ToString(); CalibrateIPose(charId); } break; case "resetCalibration": { string charId = json["characterId"]?.ToString(); ResetCalibration(charId); } break; case "calibrateHeadForward": { string charId = json["characterId"]?.ToString(); CalibrateHeadForward(charId); } break; case "autoHipsOffset": { string charId = json["characterId"]?.ToString(); AutoHipsOffset(charId); } break; case "autoCalibrateAll": { string charId = json["characterId"]?.ToString(); AutoCalibrateAll(charId); } break; default: Debug.LogWarning($"[RetargetingRemote] 알 수 없는 액션: {action}"); break; } } catch (Exception ex) { Debug.LogError($"[RetargetingRemote] 메시지 처리 오류: {ex.Message}"); } } private void SendCharacterList() { var charIds = new List(); foreach (var script in registeredCharacters) { if (script != null) { charIds.Add(script.gameObject.name); } } var response = new { type = "characterList", characters = charIds }; wsServer?.Broadcast(JsonConvert.SerializeObject(response)); } private void SendHandPosePresets() { var response = new { type = "handPosePresets", presets = handPosePresets }; wsServer?.Broadcast(JsonConvert.SerializeObject(response)); } private void SendCharacterData(string characterId) { var script = FindCharacter(characterId); if (script == null) { SendStatus(false, $"캐릭터를 찾을 수 없습니다: {characterId}"); return; } var data = new Dictionary { // 힙 위치 보정 (로컬) { "hipsVertical", GetPrivateField(script, "hipsOffsetY") }, { "hipsForward", GetPrivateField(script, "hipsOffsetZ") }, { "hipsHorizontal", GetPrivateField(script, "hipsOffsetX") }, // 무릎 위치 조정 { "kneeFrontBackWeight", GetPrivateField(script, "kneeFrontBackWeight") }, { "kneeInOutWeight", GetPrivateField(script, "kneeInOutWeight") }, // 발 IK 위치 조정 { "feetForwardBackward", GetPrivateField(script, "footFrontBackOffset") }, { "feetNarrow", GetPrivateField(script, "footInOutOffset") }, // 바닥 높이 { "floorHeight", script.floorHeight }, // 아바타 크기 { "avatarScale", GetPrivateField(script, "avatarScale") }, // 머리 크기 { "headScale", script.GetHeadScale() }, // 머리 회전 오프셋 { "headRotationOffsetX", script.headRotationOffsetX }, { "headRotationOffsetY", script.headRotationOffsetY }, { "headRotationOffsetZ", script.headRotationOffsetZ }, // 손가락 복제 모드 { "fingerCopyMode", (int)GetPrivateField(script, "fingerCopyMode") }, // 캘리브레이션 상태 { "hasCalibrationData", script.HasCachedSettings() }, // LimbWeightController 데이터 { "limbMinDistance", script.limbWeight.minDistance }, { "limbMaxDistance", script.limbWeight.maxDistance }, { "weightSmoothSpeed", script.limbWeight.weightSmoothSpeed }, { "hipsMinDistance", script.limbWeight.hipsMinDistance }, { "hipsMaxDistance", script.limbWeight.hipsMaxDistance }, { "groundHipsMinHeight", script.limbWeight.groundHipsMinHeight }, { "groundHipsMaxHeight", script.limbWeight.groundHipsMaxHeight }, { "footHeightMinThreshold", script.limbWeight.footHeightMinThreshold }, { "footHeightMaxThreshold", script.limbWeight.footHeightMaxThreshold }, { "chairSeatHeightOffset", script.limbWeight.chairSeatHeightOffset }, { "enableLeftArmIK", script.limbWeight.enableLeftArmIK }, { "enableRightArmIK", script.limbWeight.enableRightArmIK }, // ShoulderCorrection 데이터 { "shoulderBlendStrength", script.shoulderCorrection.blendStrength }, { "shoulderMaxBlend", script.shoulderCorrection.maxShoulderBlend }, { "shoulderMaxHeightDiff", script.shoulderCorrection.maxHeightDifference }, { "shoulderMinHeightDiff", script.shoulderCorrection.minHeightDifference }, { "shoulderReverseLeft", script.shoulderCorrection.reverseLeftRotation }, { "shoulderReverseRight", script.shoulderCorrection.reverseRightRotation }, // FingerShapedController 데이터 { "handPoseEnabled", script.fingerShaped.enabled }, { "leftHandEnabled", script.fingerShaped.leftHandEnabled }, { "rightHandEnabled", script.fingerShaped.rightHandEnabled }, { "leftThumbCurl", script.fingerShaped.leftThumbCurl }, { "leftIndexCurl", script.fingerShaped.leftIndexCurl }, { "leftMiddleCurl", script.fingerShaped.leftMiddleCurl }, { "leftRingCurl", script.fingerShaped.leftRingCurl }, { "leftPinkyCurl", script.fingerShaped.leftPinkyCurl }, { "leftSpreadFingers", script.fingerShaped.leftSpreadFingers }, { "rightThumbCurl", script.fingerShaped.rightThumbCurl }, { "rightIndexCurl", script.fingerShaped.rightIndexCurl }, { "rightMiddleCurl", script.fingerShaped.rightMiddleCurl }, { "rightRingCurl", script.fingerShaped.rightRingCurl }, { "rightPinkyCurl", script.fingerShaped.rightPinkyCurl }, { "rightSpreadFingers", script.fingerShaped.rightSpreadFingers }, }; var response = new { type = "characterData", characterId = characterId, data = data }; wsServer?.Broadcast(JsonConvert.SerializeObject(response)); } private void UpdateValue(string characterId, string property, float value) { var script = FindCharacter(characterId); if (script == null) return; switch (property) { // 힙 위치 보정 case "hipsVertical": SetPrivateField(script, "hipsOffsetY", value); break; case "hipsForward": SetPrivateField(script, "hipsOffsetZ", value); break; case "hipsHorizontal": SetPrivateField(script, "hipsOffsetX", value); break; // 무릎 위치 조정 case "kneeFrontBackWeight": SetPrivateField(script, "kneeFrontBackWeight", value); break; case "kneeInOutWeight": SetPrivateField(script, "kneeInOutWeight", value); break; // 발 IK 위치 조정 case "feetForwardBackward": SetPrivateField(script, "footFrontBackOffset", value); break; case "feetNarrow": SetPrivateField(script, "footInOutOffset", value); break; // 바닥 높이 case "floorHeight": script.floorHeight = value; break; // 아바타 크기 case "avatarScale": SetPrivateField(script, "avatarScale", value); break; // 머리 크기 case "headScale": script.SetHeadScale(value); break; // 머리 회전 오프셋 case "headRotationOffsetX": script.headRotationOffsetX = value; break; case "headRotationOffsetY": script.headRotationOffsetY = value; break; case "headRotationOffsetZ": script.headRotationOffsetZ = value; break; // 손가락 복제 모드 case "fingerCopyMode": SetPrivateField(script, "fingerCopyMode", (EnumsList.FingerCopyMode)(int)value); break; // LimbWeightController 속성 case "limbMinDistance": script.limbWeight.minDistance = value; break; case "limbMaxDistance": script.limbWeight.maxDistance = value; break; case "weightSmoothSpeed": script.limbWeight.weightSmoothSpeed = value; break; case "hipsMinDistance": script.limbWeight.hipsMinDistance = value; break; case "hipsMaxDistance": script.limbWeight.hipsMaxDistance = value; break; case "groundHipsMinHeight": script.limbWeight.groundHipsMinHeight = value; break; case "groundHipsMaxHeight": script.limbWeight.groundHipsMaxHeight = value; break; case "footHeightMinThreshold": script.limbWeight.footHeightMinThreshold = value; break; case "footHeightMaxThreshold": script.limbWeight.footHeightMaxThreshold = value; break; case "chairSeatHeightOffset": script.limbWeight.chairSeatHeightOffset = value; break; case "enableLeftArmIK": script.limbWeight.enableLeftArmIK = value > 0.5f; break; case "enableRightArmIK": script.limbWeight.enableRightArmIK = value > 0.5f; break; // ShoulderCorrection 속성 case "shoulderBlendStrength": script.shoulderCorrection.blendStrength = value; break; case "shoulderMaxBlend": script.shoulderCorrection.maxShoulderBlend = value; break; case "shoulderMaxHeightDiff": script.shoulderCorrection.maxHeightDifference = value; break; case "shoulderMinHeightDiff": script.shoulderCorrection.minHeightDifference = value; break; case "shoulderReverseLeft": script.shoulderCorrection.reverseLeftRotation = value > 0.5f; break; case "shoulderReverseRight": script.shoulderCorrection.reverseRightRotation = value > 0.5f; break; // FingerShapedController 속성 case "handPoseEnabled": script.fingerShaped.enabled = value > 0.5f; break; case "leftHandEnabled": script.fingerShaped.leftHandEnabled = value > 0.5f; if (script.fingerShaped.leftHandEnabled) script.fingerShaped.enabled = true; break; case "rightHandEnabled": script.fingerShaped.rightHandEnabled = value > 0.5f; if (script.fingerShaped.rightHandEnabled) script.fingerShaped.enabled = true; break; // 개별 손가락 curl 값 case "leftThumbCurl": script.fingerShaped.leftThumbCurl = value; break; case "leftIndexCurl": script.fingerShaped.leftIndexCurl = value; break; case "leftMiddleCurl": script.fingerShaped.leftMiddleCurl = value; break; case "leftRingCurl": script.fingerShaped.leftRingCurl = value; break; case "leftPinkyCurl": script.fingerShaped.leftPinkyCurl = value; break; case "leftSpreadFingers": script.fingerShaped.leftSpreadFingers = value; break; case "rightThumbCurl": script.fingerShaped.rightThumbCurl = value; break; case "rightIndexCurl": script.fingerShaped.rightIndexCurl = value; break; case "rightMiddleCurl": script.fingerShaped.rightMiddleCurl = value; break; case "rightRingCurl": script.fingerShaped.rightRingCurl = value; break; case "rightPinkyCurl": script.fingerShaped.rightPinkyCurl = value; break; case "rightSpreadFingers": script.fingerShaped.rightSpreadFingers = value; break; default: Debug.LogWarning($"[RetargetingRemote] 알 수 없는 속성: {property}"); break; } } private void ApplyHandPosePreset(string characterId, string property, string presetName) { var script = FindCharacter(characterId); if (script == null) return; var handPose = script.fingerShaped; // 스크립트 자동 활성화 handPose.enabled = true; // 해당 손 활성화 if (property == "leftHandPreset") handPose.leftHandEnabled = true; else if (property == "rightHandPreset") handPose.rightHandEnabled = true; // 프리셋 적용 float thumb = 0, index = 0, middle = 0, ring = 0, pinky = 0, spread = 0; switch (presetName) { case "가위": thumb = 1f; index = 1f; middle = -1f; ring = -1f; pinky = -1f; spread = 0.3f; break; case "바위": thumb = -1f; index = -1f; middle = -1f; ring = -1f; pinky = -1f; spread = 0f; break; case "보": thumb = 1f; index = 1f; middle = 1f; ring = 1f; pinky = 1f; spread = 1f; break; case "브이": thumb = -1f; index = 1f; middle = 1f; ring = -1f; pinky = -1f; spread = 1f; break; case "검지": thumb = -1f; index = 1f; middle = -1f; ring = -1f; pinky = -1f; spread = 0f; break; case "초기화": thumb = 0.8f; index = 0.8f; middle = 0.8f; ring = 0.8f; pinky = 0.8f; spread = 0.8f; break; } if (property == "leftHandPreset" && handPose.leftHandEnabled) { handPose.leftThumbCurl = thumb; handPose.leftIndexCurl = index; handPose.leftMiddleCurl = middle; handPose.leftRingCurl = ring; handPose.leftPinkyCurl = pinky; handPose.leftSpreadFingers = spread; } else if (property == "rightHandPreset" && handPose.rightHandEnabled) { handPose.rightThumbCurl = thumb; handPose.rightIndexCurl = index; handPose.rightMiddleCurl = middle; handPose.rightRingCurl = ring; handPose.rightPinkyCurl = pinky; handPose.rightSpreadFingers = spread; } SendCharacterData(characterId); SendStatus(true, $"{presetName} 프리셋 적용됨"); } private void CalibrateIPose(string characterId) { var script = FindCharacter(characterId); if (script == null) return; script.I_PoseCalibration(); script.SaveSettings(); // 캘리브레이션 후 설정 저장 SendCharacterData(characterId); SendStatus(true, "I-포즈 캘리브레이션 완료"); } private void ResetCalibration(string characterId) { var script = FindCharacter(characterId); if (script == null) return; script.ResetPoseAndCache(); SendCharacterData(characterId); SendStatus(true, "캘리브레이션 초기화됨"); } private void CalibrateHeadForward(string characterId) { var script = FindCharacter(characterId); if (script == null) return; script.CalibrateHeadToForward(); script.SaveSettings(); SendCharacterData(characterId); SendStatus(true, "정면 캘리브레이션 완료"); } private void AutoHipsOffset(string characterId) { var script = FindCharacter(characterId); if (script == null) return; float offset = CalculateHipsOffsetFromLegDifference(script); SetPrivateField(script, "hipsOffsetY", offset); script.SaveSettings(); SendCharacterData(characterId); SendStatus(true, $"다리 길이 자동 보정 완료: hipsOffsetY={offset:F4}"); } private void AutoCalibrateAll(string characterId) { var script = FindCharacter(characterId); if (script == null) return; var source = script.optitrackSource; Animator targetAnim = script.targetAnimator; if (source == null || targetAnim == null || !targetAnim.isHuman) { SendStatus(false, "소스 OptiTrack 또는 타겟 Animator가 설정되지 않았습니다."); return; } // Step 1: 크기 초기화 + 힙 오프셋 계산 script.ResetScale(); SetPrivateField(script, "avatarScale", 1f); SetPrivateField(script, "hipsOffsetY", CalculateHipsOffsetFromLegDifference(script)); // Step 2: 1프레임 후 목 높이 비율로 크기 조정 StartCoroutine(AutoCalibrateCoroutine(script, characterId)); } private IEnumerator AutoCalibrateCoroutine(CustomRetargetingScript script, string characterId) { yield return null; // 1프레임 대기 var source = script.optitrackSource; Animator targetAnim = script.targetAnimator; Transform sourceNeck = source.GetBoneTransform(HumanBodyBones.Neck); Transform targetNeck = targetAnim.GetBoneTransform(HumanBodyBones.Neck); if (sourceNeck == null || targetNeck == null) { SendStatus(false, "목 본을 찾을 수 없습니다."); yield break; } float scaleRatio = Mathf.Clamp(sourceNeck.position.y / Mathf.Max(targetNeck.position.y, 0.01f), 0.1f, 3f); script.SetAvatarScale(scaleRatio); SetPrivateField(script, "avatarScale", scaleRatio); yield return null; // 1프레임 대기 // Step 3: 힙 오프셋 재계산 + 머리 정면 캘리브레이션 SetPrivateField(script, "hipsOffsetY", CalculateHipsOffsetFromLegDifference(script)); script.CalibrateHeadToForward(); script.SaveSettings(); SendCharacterData(characterId); SendStatus(true, $"전체 자동 보정 완료: avatarScale={scaleRatio:F3}"); } private float CalculateHipsOffsetFromLegDifference(CustomRetargetingScript script) { var source = script.optitrackSource; Animator targetAnim = script.targetAnimator; if (source == null || targetAnim == null) return 0f; float sourceLeg = GetSourceLegLength(source); float targetLeg = GetLegLength(targetAnim); if (sourceLeg < 0.01f || targetLeg < 0.01f) return 0f; return targetLeg - sourceLeg; } private float GetSourceLegLength(OptitrackSkeletonAnimator_Mingle source) { Transform upper = source.GetBoneTransform(HumanBodyBones.LeftUpperLeg); Transform lower = source.GetBoneTransform(HumanBodyBones.LeftLowerLeg); Transform foot = source.GetBoneTransform(HumanBodyBones.LeftFoot); if (upper == null || lower == null || foot == null) return 0f; return Vector3.Distance(upper.position, lower.position) + Vector3.Distance(lower.position, foot.position); } private float GetLegLength(Animator animator) { Transform upper = animator.GetBoneTransform(HumanBodyBones.LeftUpperLeg); Transform lower = animator.GetBoneTransform(HumanBodyBones.LeftLowerLeg); Transform foot = animator.GetBoneTransform(HumanBodyBones.LeftFoot); if (upper == null || lower == null || foot == null) return 0f; return Vector3.Distance(upper.position, lower.position) + Vector3.Distance(lower.position, foot.position); } private CustomRetargetingScript FindCharacter(string characterId) { foreach (var script in registeredCharacters) { if (script != null && script.gameObject.name == characterId) { return script; } } return null; } private void BroadcastValueChanged(string characterId, string property, float value) { var response = new { type = "valueChanged", characterId = characterId, property = property, value = value }; wsServer?.Broadcast(JsonConvert.SerializeObject(response)); } private void SendStatus(bool success, string message) { var response = new { type = "status", success = success, message = message }; wsServer?.Broadcast(JsonConvert.SerializeObject(response)); } #region Reflection Helpers private T GetPrivateField(object obj, string fieldName) { try { var field = obj.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public); if (field != null) return (T)field.GetValue(obj); // 필드를 찾지 못함 — 필드명 변경이나 오타 확인 필요 Debug.LogWarning($"[RetargetingRemote] 필드를 찾을 수 없음: {fieldName} ({obj.GetType().Name}) — 필드명 변경 여부 확인"); } catch (Exception ex) { Debug.LogError($"[RetargetingRemote] 필드 읽기 오류 ({fieldName}): {ex.Message}"); } return default(T); } private void SetPrivateField(object obj, string fieldName, T value) { try { var field = obj.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public); if (field != null) { field.SetValue(obj, value); return; } // 필드를 찾지 못함 — 값이 적용되지 않음 Debug.LogError($"[RetargetingRemote] 필드를 찾을 수 없음: {fieldName} ({obj.GetType().Name}) — 필드명 변경 여부 확인"); } catch (Exception ex) { Debug.LogError($"[RetargetingRemote] 필드 설정 오류 ({fieldName}): {ex.Message}"); } } #endregion #region Public API /// /// 캐릭터 등록 /// public void RegisterCharacter(CustomRetargetingScript character) { if (!registeredCharacters.Contains(character)) { registeredCharacters.Add(character); } } /// /// 캐릭터 등록 해제 /// public void UnregisterCharacter(CustomRetargetingScript character) { registeredCharacters.Remove(character); } #endregion } }