From efc0adced895ed979ddbbaf240c37753627c3e7e Mon Sep 17 00:00:00 2001 From: user Date: Mon, 2 Feb 2026 22:57:59 +0900 Subject: [PATCH] =?UTF-8?q?ADD:=20=EB=93=9C=EB=A1=A0=20=EC=B9=B4=EB=A9=94?= =?UTF-8?q?=EB=9D=BC=20=EA=B2=8C=EC=9E=84=ED=8C=A8=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EC=96=B4=20=EC=8B=9C=EC=8A=A4=ED=85=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 게임패드(Xbox/PS)를 이용한 6DOF 드론 카메라 자유비행 모드 추가 - GamepadInputHandler: 게임패드 입력 처리 (스틱, 트리거, 버튼, D-pad) - DroneCameraMode: 관성 기반 드론 물리 시뮬레이션 및 타겟 자동추적 - CameraController: 드론 모드 토글, 프리셋별 드론 상태 저장/복원 - SystemController: 아바타 Head 콜라이더 자동 생성 및 관리 - StreamDeckServerManager: 드론 모드 WebSocket 연동 Co-Authored-By: Claude Opus 4.5 --- ...lMocap.cs => StreamingleFacialReceiver.cs} | 0 ...meta => StreamingleFacialReceiver.cs.meta} | 0 .../Streamdeck/StreamDeckServerManager.cs | 38 ++ .../Camera/CameraControlSystem.cs | 58 ++- .../Camera/DroneCameraMode.cs | 435 ++++++++++++++++++ .../Camera/DroneCameraMode.cs.meta | 2 + .../Controllers/CameraController.cs | 200 +++++++- .../Controllers/SystemController.cs | 70 ++- .../Input/GamepadInputHandler.cs | 178 +++++++ .../Input/GamepadInputHandler.cs.meta | 2 + 10 files changed, 948 insertions(+), 35 deletions(-) rename Assets/External/Ifacialmocap/{UnityRecieve_FACEMOTION3D_and_iFacialMocap.cs => StreamingleFacialReceiver.cs} (100%) rename Assets/External/Ifacialmocap/{UnityRecieve_FACEMOTION3D_and_iFacialMocap.cs.meta => StreamingleFacialReceiver.cs.meta} (100%) create mode 100644 Assets/Scripts/Streamingle/StreamingleControl/Camera/DroneCameraMode.cs create mode 100644 Assets/Scripts/Streamingle/StreamingleControl/Camera/DroneCameraMode.cs.meta create mode 100644 Assets/Scripts/Streamingle/StreamingleControl/Input/GamepadInputHandler.cs create mode 100644 Assets/Scripts/Streamingle/StreamingleControl/Input/GamepadInputHandler.cs.meta diff --git a/Assets/External/Ifacialmocap/UnityRecieve_FACEMOTION3D_and_iFacialMocap.cs b/Assets/External/Ifacialmocap/StreamingleFacialReceiver.cs similarity index 100% rename from Assets/External/Ifacialmocap/UnityRecieve_FACEMOTION3D_and_iFacialMocap.cs rename to Assets/External/Ifacialmocap/StreamingleFacialReceiver.cs diff --git a/Assets/External/Ifacialmocap/UnityRecieve_FACEMOTION3D_and_iFacialMocap.cs.meta b/Assets/External/Ifacialmocap/StreamingleFacialReceiver.cs.meta similarity index 100% rename from Assets/External/Ifacialmocap/UnityRecieve_FACEMOTION3D_and_iFacialMocap.cs.meta rename to Assets/External/Ifacialmocap/StreamingleFacialReceiver.cs.meta diff --git a/Assets/Scripts/Streamdeck/StreamDeckServerManager.cs b/Assets/Scripts/Streamdeck/StreamDeckServerManager.cs index ba1e892f..45d83bb6 100644 --- a/Assets/Scripts/Streamdeck/StreamDeckServerManager.cs +++ b/Assets/Scripts/Streamdeck/StreamDeckServerManager.cs @@ -310,6 +310,14 @@ public class StreamDeckServerManager : MonoBehaviour HandleGetCameraList(service); break; + case "toggle_drone_mode": + HandleToggleDroneMode(service); + break; + + case "get_drone_state": + HandleGetDroneState(service); + break; + case "toggle_item": HandleToggleItem(message); break; @@ -493,6 +501,36 @@ public class StreamDeckServerManager : MonoBehaviour service.SendMessage(json); } + private void HandleToggleDroneMode(StreamDeckService service) + { + if (cameraManager == null) return; + + cameraManager.ToggleDroneMode(); + + // 상태 응답 + HandleGetDroneState(service); + } + + private void HandleGetDroneState(StreamDeckService service) + { + if (cameraManager == null) return; + + var response = new + { + type = "drone_state_response", + timestamp = DateTime.UtcNow.ToString("o"), + version = "1.0", + data = new + { + is_drone_mode = cameraManager.IsDroneModeActive, + current_camera = cameraManager.GetCurrentCameraState() + } + }; + + string json = JsonConvert.SerializeObject(response); + service.SendMessage(json); + } + private void HandleToggleItem(Dictionary message) { Debug.Log($"[StreamDeckServerManager] 아이템 토글 요청 수신"); diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Camera/CameraControlSystem.cs b/Assets/Scripts/Streamingle/StreamingleControl/Camera/CameraControlSystem.cs index 0e5acc02..ca675e24 100644 --- a/Assets/Scripts/Streamingle/StreamingleControl/Camera/CameraControlSystem.cs +++ b/Assets/Scripts/Streamingle/StreamingleControl/Camera/CameraControlSystem.cs @@ -923,41 +923,39 @@ public class CameraControlSystem : MonoBehaviour { availableDOFTargets.Clear(); - // 씬의 모든 CustomRetargetingScript 찾기 - var retargetingScripts = FindObjectsByType(FindObjectsSortMode.None); - Debug.Log($"[CameraControlSystem] CustomRetargetingScript {retargetingScripts.Length}개 발견"); - - foreach (var script in retargetingScripts) + // SystemController에서 글로벌하게 관리하는 Head 타겟 목록 가져오기 + var systemController = SystemController.Instance; + if (systemController != null) { - Debug.Log($"[CameraControlSystem] 스크립트 검사 중: {script.gameObject.name}, targetAnimator = {script.targetAnimator}"); - - if (script.targetAnimator != null) + var headTargets = systemController.GetAvatarHeadTargets(); + if (headTargets != null && headTargets.Count > 0) { - // Head 본 찾기 - Transform headBone = script.targetAnimator.GetBoneTransform(HumanBodyBones.Head); - Debug.Log($"[CameraControlSystem] Head 본 찾기: {(headBone != null ? headBone.name : "null")}"); - - if (headBone != null) - { - // Head에 콜라이더가 없으면 추가 - bool hasCollider = headBone.TryGetComponent(out _); - Debug.Log($"[CameraControlSystem] {headBone.name}에 기존 콜라이더: {hasCollider}"); - - if (!hasCollider) - { - var collider = headBone.gameObject.AddComponent(); - collider.radius = 0.1f; - collider.isTrigger = true; - Debug.Log($"[CameraControlSystem] {headBone.name}에 SphereCollider 추가 완료 (radius: 0.1)"); - } - - availableDOFTargets.Add(headBone); - Debug.Log($"[CameraControlSystem] DOF 타겟 추가: {headBone.name} ({script.targetAnimator.gameObject.name})"); - } + availableDOFTargets.AddRange(headTargets); + Debug.Log($"[CameraControlSystem] SystemController에서 DOF 타겟 {availableDOFTargets.Count}개 가져옴"); + return; } } - Debug.Log($"[CameraControlSystem] 총 {availableDOFTargets.Count}개의 DOF 타겟 발견"); + // SystemController가 없거나 타겟이 비어있으면 직접 검색 (폴백) + var retargetingScripts = FindObjectsByType(FindObjectsSortMode.None); + foreach (var script in retargetingScripts) + { + if (script.targetAnimator == null) continue; + + Transform headBone = script.targetAnimator.GetBoneTransform(HumanBodyBones.Head); + if (headBone == null) continue; + + if (!headBone.TryGetComponent(out _)) + { + var collider = headBone.gameObject.AddComponent(); + collider.radius = 0.1f; + collider.isTrigger = true; + } + + availableDOFTargets.Add(headBone); + } + + Debug.Log($"[CameraControlSystem] DOF 타겟 {availableDOFTargets.Count}개 발견 (폴백)"); } private void CycleDOFTarget() diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Camera/DroneCameraMode.cs b/Assets/Scripts/Streamingle/StreamingleControl/Camera/DroneCameraMode.cs new file mode 100644 index 00000000..b3651224 --- /dev/null +++ b/Assets/Scripts/Streamingle/StreamingleControl/Camera/DroneCameraMode.cs @@ -0,0 +1,435 @@ +using UnityEngine; +using Unity.Cinemachine; + +/// +/// 드론 카메라 자유비행(6DOF) 모드. +/// GamepadInputHandler의 입력을 받아 Cinemachine 카메라를 직접 이동/회전합니다. +/// 관성 기반 물리 모델로 자연스러운 드론 느낌을 구현합니다. +/// +public class DroneCameraMode : MonoBehaviour +{ + [Header("Movement Physics")] + [SerializeField] private float acceleration = 5.0f; + [SerializeField] private float drag = 3.0f; + [SerializeField] private float maxSpeed = 10f; + + [Header("Rotation")] + [SerializeField] private float yawSpeed = 90f; + [SerializeField] private float pitchSpeed = 60f; + [SerializeField] private float rotationSmoothing = 0.1f; + + [Header("FOV Control")] + [SerializeField] private float fovSpeed = 30f; + [SerializeField] private float minFOV = 10f; + [SerializeField] private float maxFOV = 90f; + + [Header("Targeting")] + [SerializeField] private float raycastDistance = 100f; + [SerializeField] private float sphereCastRadius = 0.5f; // SphereCast 반경 (넓게 잡음) + + // 상태 + private bool isActive; + private Vector3 velocity; + private float currentYaw; + private float currentPitch; + private Quaternion targetRotation; + + // 타겟팅 (Head 본이 아닌 캐릭터 단위) + private int currentTargetIndex = -1; + private Transform currentTarget; + public Transform CurrentTarget => currentTarget; + + // 참조 + private GamepadInputHandler gamepadInput; + private CinemachineCamera targetCamera; + + // 드론 모드 진입 전 상태 저장 + private Transform savedFollow; + private Transform savedLookAt; + private Vector3 savedPosition; + private Quaternion savedRotation; + private float savedFOV; + + public bool IsActive => isActive; + + private void Awake() + { + gamepadInput = GetComponent(); + if (gamepadInput == null) + gamepadInput = gameObject.AddComponent(); + } + + /// + /// 드론 모드 활성화. 대상 Cinemachine 카메라의 Follow/LookAt을 해제하고 직접 제어를 시작합니다. + /// + public void Activate(CinemachineCamera camera) + { + if (camera == null || isActive) return; + + targetCamera = camera; + + // 현재 상태 저장 + savedFollow = camera.Follow; + savedLookAt = camera.LookAt; + savedPosition = camera.transform.position; + savedRotation = camera.transform.rotation; + savedFOV = camera.Lens.FieldOfView; + + // Cinemachine 추적 해제 → 직접 제어 + camera.Follow = null; + camera.LookAt = null; + + // 현재 회전을 오일러로 분해 + Vector3 euler = camera.transform.eulerAngles; + currentYaw = euler.y; + currentPitch = euler.x; + if (currentPitch > 180f) currentPitch -= 360f; // -180~180 범위로 + + targetRotation = camera.transform.rotation; + velocity = Vector3.zero; + isActive = true; + + Debug.Log($"[DroneCameraMode] 활성화 - 카메라: {camera.name}"); + } + + /// + /// 드론 모드 비활성화. Follow/LookAt을 복원합니다. + /// + public void Deactivate() + { + if (!isActive || targetCamera == null) return; + + // Follow/LookAt 복원 + targetCamera.Follow = savedFollow; + targetCamera.LookAt = savedLookAt; + + velocity = Vector3.zero; + isActive = false; + + Debug.Log("[DroneCameraMode] 비활성화"); + } + + /// + /// 드론 모드 비활성화 + 원래 위치로 복귀 + /// + public void DeactivateAndRestore() + { + if (!isActive || targetCamera == null) return; + + targetCamera.transform.position = savedPosition; + targetCamera.transform.rotation = savedRotation; + + var lens = targetCamera.Lens; + lens.FieldOfView = savedFOV; + targetCamera.Lens = lens; + + Deactivate(); + } + + /// + /// 현재 위치를 새 홈 포지션으로 저장 + /// + public void SaveCurrentAsHome() + { + if (!isActive || targetCamera == null) return; + + savedPosition = targetCamera.transform.position; + savedRotation = targetCamera.transform.rotation; + savedFOV = targetCamera.Lens.FieldOfView; + + Debug.Log("[DroneCameraMode] 현재 위치를 홈으로 저장"); + } + + /// + /// 홈 포지션으로 복귀 (드론 모드 유지) + /// + public void ReturnToHome() + { + if (!isActive || targetCamera == null) return; + + targetCamera.transform.position = savedPosition; + targetCamera.transform.rotation = savedRotation; + + Vector3 euler = savedRotation.eulerAngles; + currentYaw = euler.y; + currentPitch = euler.x; + if (currentPitch > 180f) currentPitch -= 360f; + + targetRotation = savedRotation; + velocity = Vector3.zero; + + var lens = targetCamera.Lens; + lens.FieldOfView = savedFOV; + targetCamera.Lens = lens; + + Debug.Log("[DroneCameraMode] 홈 위치로 복귀"); + } + + /// + /// 드론 상태를 직렬화 가능한 데이터로 반환 + /// + public DroneStateData GetState() + { + return new DroneStateData + { + isActive = isActive, + position = isActive && targetCamera != null ? targetCamera.transform.position : savedPosition, + rotation = isActive && targetCamera != null ? targetCamera.transform.eulerAngles : savedRotation.eulerAngles, + fov = isActive && targetCamera != null ? targetCamera.Lens.FieldOfView : savedFOV, + speed = velocity.magnitude, + targetName = currentTarget != null ? currentTarget.name : null + }; + } + + private void LateUpdate() + { + if (!isActive || targetCamera == null || gamepadInput == null) return; + + float dt = Time.deltaTime; + + // === 회전 === + bool hasStickInput = gamepadInput.RightStick.sqrMagnitude > 0.01f; + + if (currentTarget != null && !hasStickInput) + { + // 타겟 자동 추적: 카메라가 타겟을 부드럽게 바라봄 + Vector3 dirToTarget = currentTarget.position - targetCamera.transform.position; + if (dirToTarget.sqrMagnitude > 0.001f) + { + Quaternion lookRot = Quaternion.LookRotation(dirToTarget); + targetCamera.transform.rotation = Quaternion.Slerp( + targetCamera.transform.rotation, + lookRot, + 1f - Mathf.Exp(-dt / Mathf.Max(rotationSmoothing, 0.001f)) + ); + // yaw/pitch 동기화 (스틱 조작 시 이어서 진행되도록) + Vector3 euler = targetCamera.transform.eulerAngles; + currentYaw = euler.y; + currentPitch = euler.x; + if (currentPitch > 180f) currentPitch -= 360f; + } + } + else + { + // 수동 회전: 우스틱으로 조작 + currentYaw += gamepadInput.RightStick.x * yawSpeed * dt; + currentPitch -= gamepadInput.RightStick.y * pitchSpeed * dt; + currentPitch = Mathf.Clamp(currentPitch, -85f, 85f); + + targetRotation = Quaternion.Euler(currentPitch, currentYaw, 0f); + targetCamera.transform.rotation = Quaternion.Slerp( + targetCamera.transform.rotation, + targetRotation, + 1f - Mathf.Exp(-dt / Mathf.Max(rotationSmoothing, 0.001f)) + ); + } + + // === 이동 === + Vector3 inputDir = Vector3.zero; + + // 좌스틱 Y → 전진/후진 + inputDir += targetCamera.transform.forward * gamepadInput.LeftStick.y; + + // 좌스틱 X → 좌우 이동 + inputDir += targetCamera.transform.right * gamepadInput.LeftStick.x; + + // RT → 상승, LT → 하강 + float verticalInput = gamepadInput.RightTrigger - gamepadInput.LeftTrigger; + inputDir += Vector3.up * verticalInput; + + // 물리: 가속 + 감쇠 + velocity += inputDir * (acceleration * dt); + velocity -= velocity * (drag * dt); + + // 최대 속도 제한 + float currentMaxSpeed = maxSpeed; + if (velocity.magnitude > currentMaxSpeed) + velocity = velocity.normalized * currentMaxSpeed; + + targetCamera.transform.position += velocity * dt; + + // === FOV === + if (gamepadInput.LeftShoulderHeld || gamepadInput.RightShoulderHeld) + { + float fovDelta = 0f; + if (gamepadInput.LeftShoulderHeld) fovDelta -= fovSpeed * dt; // LB: 줌 인 (FOV 감소) + if (gamepadInput.RightShoulderHeld) fovDelta += fovSpeed * dt; // RB: 줌 아웃 (FOV 증가) + + var lens = targetCamera.Lens; + lens.FieldOfView = Mathf.Clamp(lens.FieldOfView + fovDelta, minFOV, maxFOV); + targetCamera.Lens = lens; + } + + // 타겟팅은 CameraController.HandleGamepadButtons()에서 처리 + } + + /// + /// 외부에서 호출: 카메라 중앙 SphereCast로 타겟팅 + /// + public void TryTargetFromCamera() + { + TryRaycastTarget(); + } + + /// + /// 외부에서 호출: 캐릭터 목록 순환 + /// + public void CycleTargetPublic(int direction) + { + CycleTarget(direction); + } + + /// + /// 카메라 중앙에서 SphereCast를 발사해 가장 가까운 캐릭터를 타겟팅합니다. + /// + private void TryRaycastTarget() + { + Camera mainCam = Camera.main; + if (mainCam == null) return; + + var headTargets = GetHeadTargets(); + if (headTargets == null || headTargets.Count == 0) + { + // 타겟이 없으면 SystemController에서 새로고침 시도 + var sc = SystemController.Instance; + if (sc != null) + { + sc.RefreshAvatarHeadColliders(); + headTargets = sc.GetAvatarHeadTargets(); + if (headTargets == null || headTargets.Count == 0) return; + } + else return; + } + + Ray ray = mainCam.ViewportPointToRay(new Vector3(0.5f, 0.5f, 0f)); + RaycastHit[] hits = Physics.SphereCastAll(ray, sphereCastRadius, raycastDistance); + + Transform closestHead = null; + float closestDist = float.MaxValue; + int closestIndex = -1; + + foreach (var hit in hits) + { + // Head 타겟 목록에 있는지 확인 (콜라이더 종류 무관) + int idx = headTargets.IndexOf(hit.transform); + if (idx >= 0 && hit.distance < closestDist) + { + closestHead = hit.transform; + closestDist = hit.distance; + closestIndex = idx; + } + } + + if (closestHead != null) + { + currentTarget = closestHead; + currentTargetIndex = closestIndex; + Debug.Log($"[DroneCameraMode] 타겟 설정: {closestHead.name} (거리: {closestDist:F2}m)"); + } + else + { + Debug.Log("[DroneCameraMode] 타겟을 찾지 못했습니다 - 가장 가까운 캐릭터로 설정합니다"); + // SphereCast가 안 맞으면 거리 기반으로 가장 가까운 캐릭터 선택 + SetClosestTarget(); + } + } + + /// + /// 카메라에서 가장 가까운 캐릭터를 타겟으로 설정합니다. + /// + private void SetClosestTarget() + { + var headTargets = GetHeadTargets(); + if (headTargets == null || headTargets.Count == 0 || targetCamera == null) return; + + Vector3 camPos = targetCamera.transform.position; + float closestDist = float.MaxValue; + int closestIndex = -1; + + for (int i = 0; i < headTargets.Count; i++) + { + if (headTargets[i] == null) continue; + float dist = Vector3.Distance(camPos, headTargets[i].position); + if (dist < closestDist) + { + closestDist = dist; + closestIndex = i; + } + } + + if (closestIndex >= 0) + { + currentTargetIndex = closestIndex; + currentTarget = headTargets[closestIndex]; + Debug.Log($"[DroneCameraMode] 가장 가까운 타겟: {currentTarget.name} (거리: {closestDist:F2}m)"); + } + } + + /// + /// 등록된 캐릭터 목록에서 다음/이전 캐릭터로 타겟을 전환합니다. + /// + private void CycleTarget(int direction) + { + var headTargets = GetHeadTargets(); + if (headTargets == null || headTargets.Count == 0) return; + + if (headTargets.Count == 1) + { + currentTargetIndex = 0; + currentTarget = headTargets[0]; + Debug.Log($"[DroneCameraMode] 타겟 (유일): {currentTarget.name}"); + return; + } + + currentTargetIndex += direction; + + // 순환 + if (currentTargetIndex >= headTargets.Count) + currentTargetIndex = 0; + else if (currentTargetIndex < 0) + currentTargetIndex = headTargets.Count - 1; + + currentTarget = headTargets[currentTargetIndex]; + Debug.Log($"[DroneCameraMode] 타겟 전환 [{currentTargetIndex + 1}/{headTargets.Count}]: {currentTarget.name}"); + } + + /// + /// 외부에서 타겟을 직접 설정합니다. + /// + public void SetTarget(Transform target) + { + currentTarget = target; + var headTargets = GetHeadTargets(); + currentTargetIndex = headTargets != null ? headTargets.IndexOf(target) : -1; + } + + /// + /// 현재 타겟을 해제합니다. + /// + public void ClearTarget() + { + if (currentTarget != null) + { + Debug.Log($"[DroneCameraMode] 타겟 해제: {currentTarget.name}"); + } + currentTarget = null; + currentTargetIndex = -1; + } + + private System.Collections.Generic.List GetHeadTargets() + { + var systemController = SystemController.Instance; + return systemController != null ? systemController.GetAvatarHeadTargets() : null; + } + + [System.Serializable] + public class DroneStateData + { + public bool isActive; + public Vector3 position; + public Vector3 rotation; + public float fov; + public float speed; + public string targetName; + } +} diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Camera/DroneCameraMode.cs.meta b/Assets/Scripts/Streamingle/StreamingleControl/Camera/DroneCameraMode.cs.meta new file mode 100644 index 00000000..3a70a30c --- /dev/null +++ b/Assets/Scripts/Streamingle/StreamingleControl/Camera/DroneCameraMode.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a2e67949346367641b5bbd8b4401f311 \ No newline at end of file diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Controllers/CameraController.cs b/Assets/Scripts/Streamingle/StreamingleControl/Controllers/CameraController.cs index 705f3949..65af7c50 100644 --- a/Assets/Scripts/Streamingle/StreamingleControl/Controllers/CameraController.cs +++ b/Assets/Scripts/Streamingle/StreamingleControl/Controllers/CameraController.cs @@ -287,6 +287,22 @@ public class CameraManager : MonoBehaviour, IController private InputHandler inputHandler; private CameraPreset currentPreset; + // 드론 카메라 모드 + private DroneCameraMode droneCameraMode; + private GamepadInputHandler gamepadInput; + + // 프리셋별 드론 상태 저장 + private class SavedDroneState + { + public bool wasActive; + public Vector3 position; + public Quaternion rotation; + public float fov; + public Transform target; + public int targetIndex; + } + private Dictionary savedDroneStates = new Dictionary(); + // 오빗 카메라 상태 (각도 기반) private float horizontalAngle; private float verticalAngle; @@ -365,8 +381,11 @@ public class CameraManager : MonoBehaviour, IController private void Update() { + // 게임패드는 IsValidSetup과 무관하게 항상 처리 + HandleGamepadButtons(); + if (!IsValidSetup) return; - + UpdateHotkeyRecording(); HandleCameraControls(); HandleHotkeys(); @@ -382,6 +401,114 @@ public class CameraManager : MonoBehaviour, IController } } } + + private void HandleGamepadButtons() + { + if (gamepadInput == null || !gamepadInput.IsGamepadConnected) return; + + // A 버튼: 드론 모드 토글 + if (gamepadInput.ButtonSouthPressed) + { + ToggleDroneMode(); + } + + // 드론 모드 전용 버튼 + if (droneCameraMode != null && droneCameraMode.IsActive) + { + // B 버튼: 현재 위치를 홈으로 저장 + if (gamepadInput.ButtonEastPressed) + { + droneCameraMode.SaveCurrentAsHome(); + } + + // Y 버튼: 홈 위치로 복귀 + if (gamepadInput.ButtonNorthPressed) + { + droneCameraMode.ReturnToHome(); + } + } + // D-pad 타겟팅 (드론 모드 관계없이 항상 동작) + // Update 실행 순서에 의존하지 않도록 Gamepad.current에서 직접 읽음 + if (droneCameraMode != null) + { + var gamepad = UnityEngine.InputSystem.Gamepad.current; + if (gamepad != null) + { + if (gamepad.dpad.up.wasPressedThisFrame) + droneCameraMode.TryTargetFromCamera(); + if (gamepad.dpad.down.wasPressedThisFrame) + droneCameraMode.ClearTarget(); + if (gamepad.dpad.right.wasPressedThisFrame) + droneCameraMode.CycleTargetPublic(1); + if (gamepad.dpad.left.wasPressedThisFrame) + droneCameraMode.CycleTargetPublic(-1); + } + } + + // Start 버튼: 다음 카메라 프리셋 + if (gamepadInput.StartPressed) + { + int currentIndex = currentPreset != null ? cameraPresets.IndexOf(currentPreset) : -1; + int nextIndex = (currentIndex + 1) % cameraPresets.Count; + Set(nextIndex); + } + } + + /// + /// 드론 모드를 토글합니다. 게임패드가 연결되어 있어야 합니다. + /// + public void ToggleDroneMode() + { + if (droneCameraMode == null || currentCamera == null) return; + + if (droneCameraMode.IsActive) + { + droneCameraMode.Deactivate(); + + // orbit 상태 복원 + if (currentPreset != null && currentPreset.hasOrbitState) + { + if (currentPreset.TryRestoreOrbitState(out float hAngle, out float vAngle, out float dist, out Vector3 focus, out float fov)) + { + targetHorizontalAngle = hAngle; + targetVerticalAngle = vAngle; + targetDistance = dist; + targetFocusPoint = focus; + horizontalAngle = hAngle; + verticalAngle = vAngle; + currentDistance = dist; + focusPoint = focus; + } + } + + Debug.Log("[CameraManager] 드론 모드 해제"); + } + else + { + // 드론 모드 진입 전 orbit 상태 저장 + if (currentPreset != null) + { + float currentFOV = currentPreset.virtualCamera != null ? currentPreset.virtualCamera.Lens.FieldOfView : 60f; + currentPreset.SaveOrbitState(targetHorizontalAngle, targetVerticalAngle, targetDistance, targetFocusPoint, currentFOV); + } + + droneCameraMode.Activate(currentCamera); + Debug.Log("[CameraManager] 드론 모드 진입"); + } + + // StreamDeck에 상태 변경 알림 + NotifyStreamDeckCameraStateChanged(); + } + + public bool IsDroneModeActive => droneCameraMode != null && droneCameraMode.IsActive; + + private void NotifyStreamDeckCameraStateChanged() + { + if (streamDeckManager != null) + { + streamDeckManager.NotifyCameraChanged(); + } + } #endregion #region Initialization @@ -392,6 +519,19 @@ public class CameraManager : MonoBehaviour, IController { inputHandler = gameObject.AddComponent(); } + + // 게임패드 + 드론 모드 초기화 + gamepadInput = GetComponent(); + if (gamepadInput == null) + { + gamepadInput = gameObject.AddComponent(); + } + + droneCameraMode = GetComponent(); + if (droneCameraMode == null) + { + droneCameraMode = gameObject.AddComponent(); + } } private void InitializeRawInput() @@ -556,6 +696,9 @@ public class CameraManager : MonoBehaviour, IController if (!IsValidSetup) return; if (currentCamera == null) return; + // 드론 모드 활성 시 마우스 제어 스킵 + if (droneCameraMode != null && droneCameraMode.IsActive) return; + // 현재 프리셋이 마우스 조작을 허용하지 않으면 스킵 if (currentPreset != null && !currentPreset.allowMouseControl) return; @@ -811,6 +954,26 @@ public class CameraManager : MonoBehaviour, IController var oldPreset = currentPreset; var newCameraName = newPreset.virtualCamera?.gameObject.name ?? "Unknown"; + // 드론 모드 활성 중이면 상태 저장 후 해제 + if (droneCameraMode != null && droneCameraMode.IsActive && oldPreset != null) + { + var cam = oldPreset.virtualCamera; + savedDroneStates[oldPreset] = new SavedDroneState + { + wasActive = true, + position = cam != null ? cam.transform.position : Vector3.zero, + rotation = cam != null ? cam.transform.rotation : Quaternion.identity, + fov = cam != null ? cam.Lens.FieldOfView : 60f, + target = droneCameraMode.CurrentTarget, + targetIndex = -1 + }; + droneCameraMode.Deactivate(); + } + else if (droneCameraMode != null && !droneCameraMode.IsActive && oldPreset != null) + { + // 드론 모드 비활성 상태에서 전환 시 이전 저장 상태 유지 (덮어쓰지 않음) + } + // 이전 프리셋의 orbit 상태 저장 if (oldPreset != null && oldPreset.allowMouseControl) { @@ -854,6 +1017,27 @@ public class CameraManager : MonoBehaviour, IController OnCameraChanged?.Invoke(oldPreset, newPreset); + // 이전에 저장된 드론 상태가 있으면 복원 + if (droneCameraMode != null && savedDroneStates.TryGetValue(newPreset, out var droneState) && droneState.wasActive) + { + var cam = newPreset.virtualCamera; + if (cam != null) + { + cam.transform.position = droneState.position; + cam.transform.rotation = droneState.rotation; + var lens = cam.Lens; + lens.FieldOfView = droneState.fov; + cam.Lens = lens; + } + droneCameraMode.Activate(cam); + if (droneState.target != null) + { + droneCameraMode.ClearTarget(); + // 타겟 복원은 public 메서드로 + droneCameraMode.SetTarget(droneState.target); + } + } + // 스트림덱에 카메라 변경 알림 전송 if (streamDeckManager != null) { @@ -1228,7 +1412,8 @@ public class CameraManager : MonoBehaviour, IController current_index = currentIndex, camera_name = currentPreset.virtualCamera?.gameObject.name ?? "Unknown", preset_name = currentPreset.presetName, - total_cameras = cameraPresets.Count + total_cameras = cameraPresets.Count, + is_drone_mode = IsDroneModeActive }; } @@ -1256,6 +1441,7 @@ public class CameraManager : MonoBehaviour, IController public string camera_name; public string preset_name; public int total_cameras; + public bool is_drone_mode; } #endregion @@ -1297,7 +1483,15 @@ public class CameraManager : MonoBehaviour, IController case "get_camera_list": // 카메라 목록은 GetControllerData()에서 반환됨 break; - + + case "toggle_drone_mode": + ToggleDroneMode(); + break; + + case "get_drone_state": + // 드론 상태는 GetCurrentCameraState()의 is_drone_mode로 반환됨 + break; + default: Debug.LogWarning($"[CameraManager] 알 수 없는 액션: {actionId}"); break; diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Controllers/SystemController.cs b/Assets/Scripts/Streamingle/StreamingleControl/Controllers/SystemController.cs index 0f6edd9b..2cb2d2ae 100644 --- a/Assets/Scripts/Streamingle/StreamingleControl/Controllers/SystemController.cs +++ b/Assets/Scripts/Streamingle/StreamingleControl/Controllers/SystemController.cs @@ -41,7 +41,7 @@ public class SystemController : MonoBehaviour public bool autoFindFacialMotionClients = true; [Tooltip("수동으로 지정할 페이셜 모션 클라이언트 목록 (autoFindFacialMotionClients가 false일 때 사용)")] - public List facialMotionClients = new List(); + public List facialMotionClients = new List(); [Header("Screenshot Settings")] [Tooltip("스크린샷 해상도 (기본: 4K)")] @@ -82,12 +82,22 @@ public class SystemController : MonoBehaviour [Tooltip("시작 시 리타게팅 웹 리모컨 자동 시작")] public bool autoStartRetargetingRemote = true; + [Header("아바타 Head 콜라이더")] + [Tooltip("아바타 Head 본에 자동으로 SphereCollider를 생성합니다 (DOF 타겟, 레이캐스트 등에 활용)")] + public bool autoCreateHeadColliders = true; + + [Tooltip("Head 콜라이더 반경")] + public float headColliderRadius = 0.1f; + [Header("디버그")] public bool enableDebugLog = true; private bool isRecording = false; private Material alphaMaterial; + // 글로벌 아바타 Head 타겟 목록 + private List avatarHeadTargets = new List(); + // 싱글톤 패턴 public static SystemController Instance { get; private set; } @@ -150,6 +160,12 @@ public class SystemController : MonoBehaviour // 리타게팅 웹 리모컨 초기화 InitializeRetargetingRemote(); + // 아바타 Head 콜라이더 자동 생성 + if (autoCreateHeadColliders) + { + RefreshAvatarHeadColliders(); + } + Log("SystemController 초기화 완료"); Log($"OptiTrack 클라이언트: {(optitrackClient != null ? "설정됨" : "없음")}"); Log($"Motion Recorder 개수: {motionRecorders.Count}"); @@ -368,7 +384,7 @@ public class SystemController : MonoBehaviour /// public void RefreshFacialMotionClients() { - var allClients = FindObjectsOfType(); + var allClients = FindObjectsOfType(); facialMotionClients = allClients.ToList(); Log($"Facial Motion 클라이언트 {facialMotionClients.Count}개 발견"); } @@ -799,6 +815,51 @@ public class SystemController : MonoBehaviour #endregion + #region 아바타 Head 콜라이더 관리 + + /// + /// 씬의 모든 아바타 Head 본을 찾아 SphereCollider를 생성합니다. + /// DOF 타겟, 레이캐스트 등 여러 시스템에서 공통으로 활용됩니다. + /// + public void RefreshAvatarHeadColliders() + { + avatarHeadTargets.Clear(); + + var retargetingScripts = FindObjectsByType(FindObjectsSortMode.None); + Log($"아바타 Head 콜라이더 검색: CustomRetargetingScript {retargetingScripts.Length}개 발견"); + + foreach (var script in retargetingScripts) + { + if (script.targetAnimator == null) continue; + + Transform headBone = script.targetAnimator.GetBoneTransform(HumanBodyBones.Head); + if (headBone == null) continue; + + // 콜라이더가 없으면 추가 + if (!headBone.TryGetComponent(out _)) + { + var collider = headBone.gameObject.AddComponent(); + collider.radius = headColliderRadius; + collider.isTrigger = true; + Log($"Head 콜라이더 생성: {headBone.name} ({script.targetAnimator.gameObject.name})"); + } + + avatarHeadTargets.Add(headBone); + } + + Log($"총 {avatarHeadTargets.Count}개의 아바타 Head 타겟 등록 완료"); + } + + /// + /// 등록된 아바타 Head 타겟 목록 반환 (다른 시스템에서 활용) + /// + public List GetAvatarHeadTargets() + { + return avatarHeadTargets; + } + + #endregion + #region MagicaCloth 시뮬레이션 기능 #if MAGICACLOTH2 @@ -1070,6 +1131,11 @@ public class SystemController : MonoBehaviour OpenScreenshotFolder(); break; + // 아바타 Head 콜라이더 + case "refresh_avatar_head_colliders": + RefreshAvatarHeadColliders(); + break; + // MagicaCloth 시뮬레이션 case "refresh_magica_cloth": case "reset_magica_cloth": diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Input/GamepadInputHandler.cs b/Assets/Scripts/Streamingle/StreamingleControl/Input/GamepadInputHandler.cs new file mode 100644 index 00000000..b05e0280 --- /dev/null +++ b/Assets/Scripts/Streamingle/StreamingleControl/Input/GamepadInputHandler.cs @@ -0,0 +1,178 @@ +using UnityEngine; +using UnityEngine.InputSystem; + +/// +/// 게임패드 입력을 처리하여 드론 카메라 제어에 필요한 값을 제공합니다. +/// Unity Input System의 Gamepad.current를 사용합니다. +/// +public class GamepadInputHandler : MonoBehaviour +{ + [Header("Deadzone Settings")] + [SerializeField] private float stickDeadzone = 0.15f; + + [Header("Response Curve")] + [SerializeField] private float stickExponent = 2.0f; // 비선형 응답 곡선 (2 = 제곱) + + [Header("Speed Multipliers")] + [SerializeField] private float slowMultiplier = 0.3f; // LT 누를 때 + [SerializeField] private float boostMultiplier = 3.0f; // RT 누를 때 + + // 스틱 값 (데드존 + 커브 적용 후) + public Vector2 LeftStick { get; private set; } + public Vector2 RightStick { get; private set; } + + // 트리거 값 (0~1) + public float LeftTrigger { get; private set; } + public float RightTrigger { get; private set; } + + // 속도 배율 (LT/RT 기반) + public float SpeedMultiplier { get; private set; } = 1f; + + // 버튼 상태 (이번 프레임에 눌렸는지) + public bool ButtonSouthPressed { get; private set; } // A (Xbox) / Cross (PS) + public bool ButtonEastPressed { get; private set; } // B (Xbox) / Circle (PS) + public bool ButtonNorthPressed { get; private set; } // Y (Xbox) / Triangle (PS) + public bool ButtonWestPressed { get; private set; } // X (Xbox) / Square (PS) + public bool LeftShoulderPressed { get; private set; } // LB + public bool RightShoulderPressed { get; private set; }// RB + public bool LeftShoulderHeld { get; private set; } + public bool RightShoulderHeld { get; private set; } + public bool StartPressed { get; private set; } + // D-pad는 소비형 플래그 (LateUpdate에서 읽은 뒤 자동 리셋) + private bool _dpadUp, _dpadDown, _dpadLeft, _dpadRight; + // D-pad 이전 프레임 상태 (엣지 감지용) + private bool _dpadUpHeld, _dpadDownHeld, _dpadLeftHeld, _dpadRightHeld; + public bool DpadUpPressed => _dpadUp; + public bool DpadDownPressed => _dpadDown; + public bool DpadLeftPressed => _dpadLeft; + public bool DpadRightPressed => _dpadRight; + + // 게임패드 연결 상태 + public bool IsGamepadConnected => Gamepad.current != null; + + private bool wasConnected = false; + + private void Update() + { + var gamepad = Gamepad.current; + if (gamepad == null) + { + if (wasConnected) + { + Debug.Log("[GamepadInputHandler] 게임패드 연결 해제됨"); + wasConnected = false; + } + ResetAll(); + return; + } + + if (!wasConnected) + { + Debug.Log($"[GamepadInputHandler] 게임패드 연결됨: {gamepad.displayName}"); + wasConnected = true; + } + + // 스틱 + LeftStick = ApplyDeadzoneAndCurve(gamepad.leftStick.ReadValue()); + RightStick = ApplyDeadzoneAndCurve(gamepad.rightStick.ReadValue()); + + // 트리거 + LeftTrigger = gamepad.leftTrigger.ReadValue(); + RightTrigger = gamepad.rightTrigger.ReadValue(); + + // 속도 배율 계산 + SpeedMultiplier = CalculateSpeedMultiplier(); + + // 버튼 (이번 프레임 pressed) + ButtonSouthPressed = gamepad.buttonSouth.wasPressedThisFrame; + ButtonEastPressed = gamepad.buttonEast.wasPressedThisFrame; + ButtonNorthPressed = gamepad.buttonNorth.wasPressedThisFrame; + ButtonWestPressed = gamepad.buttonWest.wasPressedThisFrame; + LeftShoulderPressed = gamepad.leftShoulder.wasPressedThisFrame; + RightShoulderPressed = gamepad.rightShoulder.wasPressedThisFrame; + LeftShoulderHeld = gamepad.leftShoulder.isPressed; + RightShoulderHeld = gamepad.rightShoulder.isPressed; + StartPressed = gamepad.startButton.wasPressedThisFrame; + // D-pad: ReadValue()로 방향 판별 (일부 게임패드에서 wasPressedThisFrame이 안 먹는 경우 대응) + Vector2 dpadValue = gamepad.dpad.ReadValue(); + bool dpadUpNow = dpadValue.y > 0.5f; + bool dpadDownNow = dpadValue.y < -0.5f; + bool dpadLeftNow = dpadValue.x < -0.5f; + bool dpadRightNow = dpadValue.x > 0.5f; + + // 엣지 감지: 이전 프레임에 안 눌려있다가 이번에 눌린 경우만 플래그 세움 + if (dpadUpNow && !_dpadUpHeld) _dpadUp = true; + if (dpadDownNow && !_dpadDownHeld) _dpadDown = true; + if (dpadLeftNow && !_dpadLeftHeld) _dpadLeft = true; + if (dpadRightNow && !_dpadRightHeld) _dpadRight = true; + + _dpadUpHeld = dpadUpNow; + _dpadDownHeld = dpadDownNow; + _dpadLeftHeld = dpadLeftNow; + _dpadRightHeld = dpadRightNow; + } + + private Vector2 ApplyDeadzoneAndCurve(Vector2 raw) + { + float magnitude = raw.magnitude; + if (magnitude < stickDeadzone) + return Vector2.zero; + + // 데드존 이후 0~1로 리매핑 + float normalized = (magnitude - stickDeadzone) / (1f - stickDeadzone); + normalized = Mathf.Clamp01(normalized); + + // 응답 곡선 적용 + float curved = Mathf.Pow(normalized, stickExponent); + + return raw.normalized * curved; + } + + private float CalculateSpeedMultiplier() + { + // LT와 RT가 동시에 눌리면 기본 속도 + if (LeftTrigger > 0.1f && RightTrigger > 0.1f) + return 1f; + + if (LeftTrigger > 0.1f) + return Mathf.Lerp(1f, slowMultiplier, LeftTrigger); + + if (RightTrigger > 0.1f) + return Mathf.Lerp(1f, boostMultiplier, RightTrigger); + + return 1f; + } + + /// + /// D-pad 플래그를 소비합니다. LateUpdate에서 읽은 후 호출해주세요. + /// + public void ConsumeDpad() + { + _dpadUp = false; + _dpadDown = false; + _dpadLeft = false; + _dpadRight = false; + } + + private void ResetAll() + { + LeftStick = Vector2.zero; + RightStick = Vector2.zero; + LeftTrigger = 0f; + RightTrigger = 0f; + SpeedMultiplier = 1f; + ButtonSouthPressed = false; + ButtonEastPressed = false; + ButtonNorthPressed = false; + ButtonWestPressed = false; + LeftShoulderPressed = false; + RightShoulderPressed = false; + LeftShoulderHeld = false; + RightShoulderHeld = false; + StartPressed = false; + _dpadUp = false; + _dpadDown = false; + _dpadLeft = false; + _dpadRight = false; + } +} diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Input/GamepadInputHandler.cs.meta b/Assets/Scripts/Streamingle/StreamingleControl/Input/GamepadInputHandler.cs.meta new file mode 100644 index 00000000..05126d3c --- /dev/null +++ b/Assets/Scripts/Streamingle/StreamingleControl/Input/GamepadInputHandler.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d5db7d43fd93d24449614ea893fe209d \ No newline at end of file