671 lines
24 KiB
C#
671 lines
24 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Reflection;
|
|
using UnityEngine;
|
|
using Newtonsoft.Json;
|
|
using Newtonsoft.Json.Linq;
|
|
|
|
namespace KindRetargeting.Remote
|
|
{
|
|
/// <summary>
|
|
/// 리타게팅 원격 제어 컨트롤러
|
|
/// HTTP 서버와 WebSocket 서버를 관리하고 메시지를 처리합니다.
|
|
/// </summary>
|
|
public class RetargetingRemoteController : MonoBehaviour
|
|
{
|
|
[Header("서버 설정")]
|
|
[SerializeField] private int httpPort = 8080;
|
|
[SerializeField] private int wsPort = 8081;
|
|
[SerializeField] private bool autoStart = true;
|
|
|
|
[Header("캐릭터 등록")]
|
|
[SerializeField] private List<CustomRetargetingScript> registeredCharacters = new List<CustomRetargetingScript>();
|
|
|
|
private RetargetingHTTPServer httpServer;
|
|
private RetargetingWebSocketServer wsServer;
|
|
|
|
private Queue<Action> mainThreadActions = new Queue<Action>();
|
|
|
|
public bool IsRunning => httpServer?.IsRunning == true && wsServer?.IsRunning == true;
|
|
public int HttpPort { get => httpPort; set => httpPort = value; }
|
|
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;
|
|
|
|
httpServer = new RetargetingHTTPServer(httpPort, wsPort);
|
|
wsServer = new RetargetingWebSocketServer(wsPort);
|
|
|
|
wsServer.OnMessageReceived += OnWebSocketMessage;
|
|
|
|
httpServer.Start();
|
|
wsServer.Start();
|
|
|
|
Debug.Log($"[RetargetingRemote] 서버 시작됨 - HTTP: {httpPort}, WS: {wsPort}");
|
|
}
|
|
|
|
public void StopServer()
|
|
{
|
|
wsServer?.Stop();
|
|
httpServer?.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<float>() ?? 0f;
|
|
UpdateValue(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 "calibrateMingleOpen":
|
|
{
|
|
string charId = json["characterId"]?.ToString();
|
|
CalibrateMingleOpen(charId);
|
|
}
|
|
break;
|
|
|
|
case "calibrateMingleClose":
|
|
{
|
|
string charId = json["characterId"]?.ToString();
|
|
CalibrateMingleClose(charId);
|
|
}
|
|
break;
|
|
|
|
case "calibrateHeadForward":
|
|
{
|
|
string charId = json["characterId"]?.ToString();
|
|
CalibrateHeadForward(charId);
|
|
}
|
|
break;
|
|
|
|
default:
|
|
Debug.LogWarning($"[RetargetingRemote] 알 수 없는 액션: {action}");
|
|
break;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogError($"[RetargetingRemote] 메시지 처리 오류: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private void SendCharacterList()
|
|
{
|
|
var charIds = new List<string>();
|
|
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 limbWeight = script.GetComponent<LimbWeightController>();
|
|
var handPose = script.GetComponent<FingerShapedController>();
|
|
|
|
var data = new Dictionary<string, object>
|
|
{
|
|
// 힙 위치 보정 (로컬)
|
|
{ "hipsVertical", GetPrivateField<float>(script, "hipsOffsetY") },
|
|
{ "hipsForward", GetPrivateField<float>(script, "hipsOffsetZ") },
|
|
{ "hipsHorizontal", GetPrivateField<float>(script, "hipsOffsetX") },
|
|
|
|
// 무릎 위치 조정
|
|
{ "kneeFrontBackWeight", GetPrivateField<float>(script, "kneeFrontBackWeight") },
|
|
{ "kneeInOutWeight", GetPrivateField<float>(script, "kneeInOutWeight") },
|
|
|
|
// 발 IK 위치 조정
|
|
{ "feetForwardBackward", GetPrivateField<float>(script, "footFrontBackOffset") },
|
|
{ "feetNarrow", GetPrivateField<float>(script, "footInOutOffset") },
|
|
|
|
// 바닥 높이
|
|
{ "floorHeight", script.floorHeight },
|
|
|
|
// 아바타 크기
|
|
{ "avatarScale", GetPrivateField<float>(script, "avatarScale") },
|
|
|
|
// 머리 크기
|
|
{ "headScale", script.GetHeadScale() },
|
|
|
|
// 머리 회전 오프셋
|
|
{ "headRotationOffsetX", script.headRotationOffsetX },
|
|
{ "headRotationOffsetY", script.headRotationOffsetY },
|
|
{ "headRotationOffsetZ", script.headRotationOffsetZ },
|
|
|
|
// 손가락 복제 모드
|
|
{ "fingerCopyMode", (int)GetPrivateField<EnumsList.FingerCopyMode>(script, "fingerCopyMode") },
|
|
|
|
// 모션 설정
|
|
{ "useMotionFilter", GetPrivateField<bool>(script, "useMotionFilter") },
|
|
{ "filterBufferSize", GetPrivateField<int>(script, "filterBufferSize") },
|
|
{ "useBodyRoughMotion", GetPrivateField<bool>(script, "useBodyRoughMotion") },
|
|
{ "bodyRoughness", GetPrivateField<float>(script, "bodyRoughness") },
|
|
|
|
// 캘리브레이션 상태
|
|
{ "hasCalibrationData", script.HasCachedSettings() }
|
|
};
|
|
|
|
// LimbWeightController 데이터
|
|
if (limbWeight != null)
|
|
{
|
|
data["limbMinDistance"] = limbWeight.minDistance;
|
|
data["limbMaxDistance"] = limbWeight.maxDistance;
|
|
data["weightSmoothSpeed"] = limbWeight.weightSmoothSpeed;
|
|
data["hipsMinDistance"] = limbWeight.hipsMinDistance;
|
|
data["hipsMaxDistance"] = limbWeight.hipsMaxDistance;
|
|
data["groundHipsMinHeight"] = limbWeight.groundHipsMinHeight;
|
|
data["groundHipsMaxHeight"] = limbWeight.groundHipsMaxHeight;
|
|
data["footHeightMinThreshold"] = limbWeight.footHeightMinThreshold;
|
|
data["footHeightMaxThreshold"] = limbWeight.footHeightMaxThreshold;
|
|
data["chairSeatHeightOffset"] = limbWeight.chairSeatHeightOffset;
|
|
}
|
|
|
|
// FingerShapedController 데이터
|
|
if (handPose != null)
|
|
{
|
|
data["handPoseEnabled"] = handPose.enabled;
|
|
data["leftHandEnabled"] = handPose.leftHandEnabled;
|
|
data["rightHandEnabled"] = handPose.rightHandEnabled;
|
|
}
|
|
else
|
|
{
|
|
data["handPoseEnabled"] = false;
|
|
data["leftHandEnabled"] = false;
|
|
data["rightHandEnabled"] = false;
|
|
}
|
|
|
|
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;
|
|
|
|
var limbWeight = script.GetComponent<LimbWeightController>();
|
|
var handPose = script.GetComponent<FingerShapedController>();
|
|
|
|
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;
|
|
|
|
// 모션 설정
|
|
case "useMotionFilter":
|
|
SetPrivateField(script, "useMotionFilter", value > 0.5f);
|
|
break;
|
|
case "filterBufferSize":
|
|
SetPrivateField(script, "filterBufferSize", (int)value);
|
|
break;
|
|
case "useBodyRoughMotion":
|
|
SetPrivateField(script, "useBodyRoughMotion", value > 0.5f);
|
|
break;
|
|
case "bodyRoughness":
|
|
SetPrivateField(script, "bodyRoughness", value);
|
|
break;
|
|
|
|
// LimbWeightController 속성
|
|
case "limbMinDistance":
|
|
if (limbWeight != null) limbWeight.minDistance = value;
|
|
break;
|
|
case "limbMaxDistance":
|
|
if (limbWeight != null) limbWeight.maxDistance = value;
|
|
break;
|
|
case "weightSmoothSpeed":
|
|
if (limbWeight != null) limbWeight.weightSmoothSpeed = value;
|
|
break;
|
|
case "hipsMinDistance":
|
|
if (limbWeight != null) limbWeight.hipsMinDistance = value;
|
|
break;
|
|
case "hipsMaxDistance":
|
|
if (limbWeight != null) limbWeight.hipsMaxDistance = value;
|
|
break;
|
|
case "groundHipsMinHeight":
|
|
if (limbWeight != null) limbWeight.groundHipsMinHeight = value;
|
|
break;
|
|
case "groundHipsMaxHeight":
|
|
if (limbWeight != null) limbWeight.groundHipsMaxHeight = value;
|
|
break;
|
|
case "footHeightMinThreshold":
|
|
if (limbWeight != null) limbWeight.footHeightMinThreshold = value;
|
|
break;
|
|
case "footHeightMaxThreshold":
|
|
if (limbWeight != null) limbWeight.footHeightMaxThreshold = value;
|
|
break;
|
|
case "chairSeatHeightOffset":
|
|
if (limbWeight != null) limbWeight.chairSeatHeightOffset = value;
|
|
break;
|
|
|
|
// FingerShapedController 속성
|
|
case "handPoseEnabled":
|
|
if (handPose != null)
|
|
handPose.enabled = value > 0.5f;
|
|
break;
|
|
case "leftHandEnabled":
|
|
if (handPose != null)
|
|
{
|
|
handPose.leftHandEnabled = value > 0.5f;
|
|
if (handPose.leftHandEnabled)
|
|
handPose.enabled = true;
|
|
}
|
|
break;
|
|
case "rightHandEnabled":
|
|
if (handPose != null)
|
|
{
|
|
handPose.rightHandEnabled = value > 0.5f;
|
|
if (handPose.rightHandEnabled)
|
|
handPose.enabled = true;
|
|
}
|
|
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.GetComponent<FingerShapedController>();
|
|
if (handPose == null) return;
|
|
|
|
// 스크립트 자동 활성화
|
|
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;
|
|
}
|
|
|
|
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 CalibrateMingleOpen(string characterId)
|
|
{
|
|
var script = FindCharacter(characterId);
|
|
if (script == null) return;
|
|
|
|
script.CalibrateMingleOpen();
|
|
script.SaveSettings();
|
|
|
|
SendCharacterData(characterId);
|
|
SendStatus(true, "Mingle 펼침 캘리브레이션 완료");
|
|
}
|
|
|
|
private void CalibrateMingleClose(string characterId)
|
|
{
|
|
var script = FindCharacter(characterId);
|
|
if (script == null) return;
|
|
|
|
script.CalibrateMingleClose();
|
|
script.SaveSettings();
|
|
|
|
SendCharacterData(characterId);
|
|
SendStatus(true, "Mingle 모음 캘리브레이션 완료");
|
|
}
|
|
|
|
private void CalibrateHeadForward(string characterId)
|
|
{
|
|
var script = FindCharacter(characterId);
|
|
if (script == null) return;
|
|
|
|
script.CalibrateHeadToForward();
|
|
script.SaveSettings();
|
|
|
|
SendCharacterData(characterId);
|
|
SendStatus(true, "정면 캘리브레이션 완료");
|
|
}
|
|
|
|
private CustomRetargetingScript FindCharacter(string characterId)
|
|
{
|
|
foreach (var script in registeredCharacters)
|
|
{
|
|
if (script != null && script.gameObject.name == characterId)
|
|
{
|
|
return script;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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<T>(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);
|
|
}
|
|
}
|
|
catch (Exception) { }
|
|
|
|
return default(T);
|
|
}
|
|
|
|
private void SetPrivateField<T>(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);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogError($"[RetargetingRemote] 필드 설정 오류 ({fieldName}): {ex.Message}");
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Public API
|
|
|
|
/// <summary>
|
|
/// 캐릭터 등록
|
|
/// </summary>
|
|
public void RegisterCharacter(CustomRetargetingScript character)
|
|
{
|
|
if (!registeredCharacters.Contains(character))
|
|
{
|
|
registeredCharacters.Add(character);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 캐릭터 등록 해제
|
|
/// </summary>
|
|
public void UnregisterCharacter(CustomRetargetingScript character)
|
|
{
|
|
registeredCharacters.Remove(character);
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
}
|