ADD: 드론 카메라 게임패드 제어 시스템

게임패드(Xbox/PS)를 이용한 6DOF 드론 카메라 자유비행 모드 추가
- GamepadInputHandler: 게임패드 입력 처리 (스틱, 트리거, 버튼, D-pad)
- DroneCameraMode: 관성 기반 드론 물리 시뮬레이션 및 타겟 자동추적
- CameraController: 드론 모드 토글, 프리셋별 드론 상태 저장/복원
- SystemController: 아바타 Head 콜라이더 자동 생성 및 관리
- StreamDeckServerManager: 드론 모드 WebSocket 연동

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
user 2026-02-02 22:57:59 +09:00
parent 00ba416b74
commit efc0adced8
10 changed files with 948 additions and 35 deletions

View File

@ -310,6 +310,14 @@ public class StreamDeckServerManager : MonoBehaviour
HandleGetCameraList(service); HandleGetCameraList(service);
break; break;
case "toggle_drone_mode":
HandleToggleDroneMode(service);
break;
case "get_drone_state":
HandleGetDroneState(service);
break;
case "toggle_item": case "toggle_item":
HandleToggleItem(message); HandleToggleItem(message);
break; break;
@ -493,6 +501,36 @@ public class StreamDeckServerManager : MonoBehaviour
service.SendMessage(json); 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<string, object> message) private void HandleToggleItem(Dictionary<string, object> message)
{ {
Debug.Log($"[StreamDeckServerManager] 아이템 토글 요청 수신"); Debug.Log($"[StreamDeckServerManager] 아이템 토글 요청 수신");

View File

@ -923,41 +923,39 @@ public class CameraControlSystem : MonoBehaviour
{ {
availableDOFTargets.Clear(); availableDOFTargets.Clear();
// 씬의 모든 CustomRetargetingScript 찾기 // SystemController에서 글로벌하게 관리하는 Head 타겟 목록 가져오기
var retargetingScripts = FindObjectsByType<KindRetargeting.CustomRetargetingScript>(FindObjectsSortMode.None); var systemController = SystemController.Instance;
Debug.Log($"[CameraControlSystem] CustomRetargetingScript {retargetingScripts.Length}개 발견"); if (systemController != null)
{
var headTargets = systemController.GetAvatarHeadTargets();
if (headTargets != null && headTargets.Count > 0)
{
availableDOFTargets.AddRange(headTargets);
Debug.Log($"[CameraControlSystem] SystemController에서 DOF 타겟 {availableDOFTargets.Count}개 가져옴");
return;
}
}
// SystemController가 없거나 타겟이 비어있으면 직접 검색 (폴백)
var retargetingScripts = FindObjectsByType<KindRetargeting.CustomRetargetingScript>(FindObjectsSortMode.None);
foreach (var script in retargetingScripts) foreach (var script in retargetingScripts)
{ {
Debug.Log($"[CameraControlSystem] 스크립트 검사 중: {script.gameObject.name}, targetAnimator = {script.targetAnimator}"); if (script.targetAnimator == null) continue;
if (script.targetAnimator != null)
{
// Head 본 찾기
Transform headBone = script.targetAnimator.GetBoneTransform(HumanBodyBones.Head); Transform headBone = script.targetAnimator.GetBoneTransform(HumanBodyBones.Head);
Debug.Log($"[CameraControlSystem] Head 본 찾기: {(headBone != null ? headBone.name : "null")}"); if (headBone == null) continue;
if (headBone != null) if (!headBone.TryGetComponent<SphereCollider>(out _))
{
// Head에 콜라이더가 없으면 추가
bool hasCollider = headBone.TryGetComponent<SphereCollider>(out _);
Debug.Log($"[CameraControlSystem] {headBone.name}에 기존 콜라이더: {hasCollider}");
if (!hasCollider)
{ {
var collider = headBone.gameObject.AddComponent<SphereCollider>(); var collider = headBone.gameObject.AddComponent<SphereCollider>();
collider.radius = 0.1f; collider.radius = 0.1f;
collider.isTrigger = true; collider.isTrigger = true;
Debug.Log($"[CameraControlSystem] {headBone.name}에 SphereCollider 추가 완료 (radius: 0.1)");
} }
availableDOFTargets.Add(headBone); availableDOFTargets.Add(headBone);
Debug.Log($"[CameraControlSystem] DOF 타겟 추가: {headBone.name} ({script.targetAnimator.gameObject.name})");
}
}
} }
Debug.Log($"[CameraControlSystem] 총 {availableDOFTargets.Count}개의 DOF 타겟 발견"); Debug.Log($"[CameraControlSystem] DOF 타겟 {availableDOFTargets.Count}개 발견 (폴백)");
} }
private void CycleDOFTarget() private void CycleDOFTarget()

View File

@ -0,0 +1,435 @@
using UnityEngine;
using Unity.Cinemachine;
/// <summary>
/// 드론 카메라 자유비행(6DOF) 모드.
/// GamepadInputHandler의 입력을 받아 Cinemachine 카메라를 직접 이동/회전합니다.
/// 관성 기반 물리 모델로 자연스러운 드론 느낌을 구현합니다.
/// </summary>
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<GamepadInputHandler>();
if (gamepadInput == null)
gamepadInput = gameObject.AddComponent<GamepadInputHandler>();
}
/// <summary>
/// 드론 모드 활성화. 대상 Cinemachine 카메라의 Follow/LookAt을 해제하고 직접 제어를 시작합니다.
/// </summary>
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}");
}
/// <summary>
/// 드론 모드 비활성화. Follow/LookAt을 복원합니다.
/// </summary>
public void Deactivate()
{
if (!isActive || targetCamera == null) return;
// Follow/LookAt 복원
targetCamera.Follow = savedFollow;
targetCamera.LookAt = savedLookAt;
velocity = Vector3.zero;
isActive = false;
Debug.Log("[DroneCameraMode] 비활성화");
}
/// <summary>
/// 드론 모드 비활성화 + 원래 위치로 복귀
/// </summary>
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();
}
/// <summary>
/// 현재 위치를 새 홈 포지션으로 저장
/// </summary>
public void SaveCurrentAsHome()
{
if (!isActive || targetCamera == null) return;
savedPosition = targetCamera.transform.position;
savedRotation = targetCamera.transform.rotation;
savedFOV = targetCamera.Lens.FieldOfView;
Debug.Log("[DroneCameraMode] 현재 위치를 홈으로 저장");
}
/// <summary>
/// 홈 포지션으로 복귀 (드론 모드 유지)
/// </summary>
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] 홈 위치로 복귀");
}
/// <summary>
/// 드론 상태를 직렬화 가능한 데이터로 반환
/// </summary>
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()에서 처리
}
/// <summary>
/// 외부에서 호출: 카메라 중앙 SphereCast로 타겟팅
/// </summary>
public void TryTargetFromCamera()
{
TryRaycastTarget();
}
/// <summary>
/// 외부에서 호출: 캐릭터 목록 순환
/// </summary>
public void CycleTargetPublic(int direction)
{
CycleTarget(direction);
}
/// <summary>
/// 카메라 중앙에서 SphereCast를 발사해 가장 가까운 캐릭터를 타겟팅합니다.
/// </summary>
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();
}
}
/// <summary>
/// 카메라에서 가장 가까운 캐릭터를 타겟으로 설정합니다.
/// </summary>
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)");
}
}
/// <summary>
/// 등록된 캐릭터 목록에서 다음/이전 캐릭터로 타겟을 전환합니다.
/// </summary>
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}");
}
/// <summary>
/// 외부에서 타겟을 직접 설정합니다.
/// </summary>
public void SetTarget(Transform target)
{
currentTarget = target;
var headTargets = GetHeadTargets();
currentTargetIndex = headTargets != null ? headTargets.IndexOf(target) : -1;
}
/// <summary>
/// 현재 타겟을 해제합니다.
/// </summary>
public void ClearTarget()
{
if (currentTarget != null)
{
Debug.Log($"[DroneCameraMode] 타겟 해제: {currentTarget.name}");
}
currentTarget = null;
currentTargetIndex = -1;
}
private System.Collections.Generic.List<Transform> 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;
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a2e67949346367641b5bbd8b4401f311

View File

@ -287,6 +287,22 @@ public class CameraManager : MonoBehaviour, IController
private InputHandler inputHandler; private InputHandler inputHandler;
private CameraPreset currentPreset; 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<CameraPreset, SavedDroneState> savedDroneStates = new Dictionary<CameraPreset, SavedDroneState>();
// 오빗 카메라 상태 (각도 기반) // 오빗 카메라 상태 (각도 기반)
private float horizontalAngle; private float horizontalAngle;
private float verticalAngle; private float verticalAngle;
@ -365,6 +381,9 @@ public class CameraManager : MonoBehaviour, IController
private void Update() private void Update()
{ {
// 게임패드는 IsValidSetup과 무관하게 항상 처리
HandleGamepadButtons();
if (!IsValidSetup) return; if (!IsValidSetup) return;
UpdateHotkeyRecording(); UpdateHotkeyRecording();
@ -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);
}
}
/// <summary>
/// 드론 모드를 토글합니다. 게임패드가 연결되어 있어야 합니다.
/// </summary>
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 #endregion
#region Initialization #region Initialization
@ -392,6 +519,19 @@ public class CameraManager : MonoBehaviour, IController
{ {
inputHandler = gameObject.AddComponent<InputHandler>(); inputHandler = gameObject.AddComponent<InputHandler>();
} }
// 게임패드 + 드론 모드 초기화
gamepadInput = GetComponent<GamepadInputHandler>();
if (gamepadInput == null)
{
gamepadInput = gameObject.AddComponent<GamepadInputHandler>();
}
droneCameraMode = GetComponent<DroneCameraMode>();
if (droneCameraMode == null)
{
droneCameraMode = gameObject.AddComponent<DroneCameraMode>();
}
} }
private void InitializeRawInput() private void InitializeRawInput()
@ -556,6 +696,9 @@ public class CameraManager : MonoBehaviour, IController
if (!IsValidSetup) return; if (!IsValidSetup) return;
if (currentCamera == null) return; if (currentCamera == null) return;
// 드론 모드 활성 시 마우스 제어 스킵
if (droneCameraMode != null && droneCameraMode.IsActive) return;
// 현재 프리셋이 마우스 조작을 허용하지 않으면 스킵 // 현재 프리셋이 마우스 조작을 허용하지 않으면 스킵
if (currentPreset != null && !currentPreset.allowMouseControl) return; if (currentPreset != null && !currentPreset.allowMouseControl) return;
@ -811,6 +954,26 @@ public class CameraManager : MonoBehaviour, IController
var oldPreset = currentPreset; var oldPreset = currentPreset;
var newCameraName = newPreset.virtualCamera?.gameObject.name ?? "Unknown"; 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 상태 저장 // 이전 프리셋의 orbit 상태 저장
if (oldPreset != null && oldPreset.allowMouseControl) if (oldPreset != null && oldPreset.allowMouseControl)
{ {
@ -854,6 +1017,27 @@ public class CameraManager : MonoBehaviour, IController
OnCameraChanged?.Invoke(oldPreset, newPreset); 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) if (streamDeckManager != null)
{ {
@ -1228,7 +1412,8 @@ public class CameraManager : MonoBehaviour, IController
current_index = currentIndex, current_index = currentIndex,
camera_name = currentPreset.virtualCamera?.gameObject.name ?? "Unknown", camera_name = currentPreset.virtualCamera?.gameObject.name ?? "Unknown",
preset_name = currentPreset.presetName, 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 camera_name;
public string preset_name; public string preset_name;
public int total_cameras; public int total_cameras;
public bool is_drone_mode;
} }
#endregion #endregion
@ -1298,6 +1484,14 @@ public class CameraManager : MonoBehaviour, IController
// 카메라 목록은 GetControllerData()에서 반환됨 // 카메라 목록은 GetControllerData()에서 반환됨
break; break;
case "toggle_drone_mode":
ToggleDroneMode();
break;
case "get_drone_state":
// 드론 상태는 GetCurrentCameraState()의 is_drone_mode로 반환됨
break;
default: default:
Debug.LogWarning($"[CameraManager] 알 수 없는 액션: {actionId}"); Debug.LogWarning($"[CameraManager] 알 수 없는 액션: {actionId}");
break; break;

View File

@ -41,7 +41,7 @@ public class SystemController : MonoBehaviour
public bool autoFindFacialMotionClients = true; public bool autoFindFacialMotionClients = true;
[Tooltip("수동으로 지정할 페이셜 모션 클라이언트 목록 (autoFindFacialMotionClients가 false일 때 사용)")] [Tooltip("수동으로 지정할 페이셜 모션 클라이언트 목록 (autoFindFacialMotionClients가 false일 때 사용)")]
public List<UnityRecieve_FACEMOTION3D_and_iFacialMocap> facialMotionClients = new List<UnityRecieve_FACEMOTION3D_and_iFacialMocap>(); public List<StreamingleFacialReceiver> facialMotionClients = new List<StreamingleFacialReceiver>();
[Header("Screenshot Settings")] [Header("Screenshot Settings")]
[Tooltip("스크린샷 해상도 (기본: 4K)")] [Tooltip("스크린샷 해상도 (기본: 4K)")]
@ -82,12 +82,22 @@ public class SystemController : MonoBehaviour
[Tooltip("시작 시 리타게팅 웹 리모컨 자동 시작")] [Tooltip("시작 시 리타게팅 웹 리모컨 자동 시작")]
public bool autoStartRetargetingRemote = true; public bool autoStartRetargetingRemote = true;
[Header("아바타 Head 콜라이더")]
[Tooltip("아바타 Head 본에 자동으로 SphereCollider를 생성합니다 (DOF 타겟, 레이캐스트 등에 활용)")]
public bool autoCreateHeadColliders = true;
[Tooltip("Head 콜라이더 반경")]
public float headColliderRadius = 0.1f;
[Header("디버그")] [Header("디버그")]
public bool enableDebugLog = true; public bool enableDebugLog = true;
private bool isRecording = false; private bool isRecording = false;
private Material alphaMaterial; private Material alphaMaterial;
// 글로벌 아바타 Head 타겟 목록
private List<Transform> avatarHeadTargets = new List<Transform>();
// 싱글톤 패턴 // 싱글톤 패턴
public static SystemController Instance { get; private set; } public static SystemController Instance { get; private set; }
@ -150,6 +160,12 @@ public class SystemController : MonoBehaviour
// 리타게팅 웹 리모컨 초기화 // 리타게팅 웹 리모컨 초기화
InitializeRetargetingRemote(); InitializeRetargetingRemote();
// 아바타 Head 콜라이더 자동 생성
if (autoCreateHeadColliders)
{
RefreshAvatarHeadColliders();
}
Log("SystemController 초기화 완료"); Log("SystemController 초기화 완료");
Log($"OptiTrack 클라이언트: {(optitrackClient != null ? "" : "")}"); Log($"OptiTrack 클라이언트: {(optitrackClient != null ? "" : "")}");
Log($"Motion Recorder 개수: {motionRecorders.Count}"); Log($"Motion Recorder 개수: {motionRecorders.Count}");
@ -368,7 +384,7 @@ public class SystemController : MonoBehaviour
/// </summary> /// </summary>
public void RefreshFacialMotionClients() public void RefreshFacialMotionClients()
{ {
var allClients = FindObjectsOfType<UnityRecieve_FACEMOTION3D_and_iFacialMocap>(); var allClients = FindObjectsOfType<StreamingleFacialReceiver>();
facialMotionClients = allClients.ToList(); facialMotionClients = allClients.ToList();
Log($"Facial Motion 클라이언트 {facialMotionClients.Count}개 발견"); Log($"Facial Motion 클라이언트 {facialMotionClients.Count}개 발견");
} }
@ -799,6 +815,51 @@ public class SystemController : MonoBehaviour
#endregion #endregion
#region Head
/// <summary>
/// 씬의 모든 아바타 Head 본을 찾아 SphereCollider를 생성합니다.
/// DOF 타겟, 레이캐스트 등 여러 시스템에서 공통으로 활용됩니다.
/// </summary>
public void RefreshAvatarHeadColliders()
{
avatarHeadTargets.Clear();
var retargetingScripts = FindObjectsByType<CustomRetargetingScript>(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<SphereCollider>(out _))
{
var collider = headBone.gameObject.AddComponent<SphereCollider>();
collider.radius = headColliderRadius;
collider.isTrigger = true;
Log($"Head 콜라이더 생성: {headBone.name} ({script.targetAnimator.gameObject.name})");
}
avatarHeadTargets.Add(headBone);
}
Log($"총 {avatarHeadTargets.Count}개의 아바타 Head 타겟 등록 완료");
}
/// <summary>
/// 등록된 아바타 Head 타겟 목록 반환 (다른 시스템에서 활용)
/// </summary>
public List<Transform> GetAvatarHeadTargets()
{
return avatarHeadTargets;
}
#endregion
#region MagicaCloth #region MagicaCloth
#if MAGICACLOTH2 #if MAGICACLOTH2
@ -1070,6 +1131,11 @@ public class SystemController : MonoBehaviour
OpenScreenshotFolder(); OpenScreenshotFolder();
break; break;
// 아바타 Head 콜라이더
case "refresh_avatar_head_colliders":
RefreshAvatarHeadColliders();
break;
// MagicaCloth 시뮬레이션 // MagicaCloth 시뮬레이션
case "refresh_magica_cloth": case "refresh_magica_cloth":
case "reset_magica_cloth": case "reset_magica_cloth":

View File

@ -0,0 +1,178 @@
using UnityEngine;
using UnityEngine.InputSystem;
/// <summary>
/// 게임패드 입력을 처리하여 드론 카메라 제어에 필요한 값을 제공합니다.
/// Unity Input System의 Gamepad.current를 사용합니다.
/// </summary>
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;
}
/// <summary>
/// D-pad 플래그를 소비합니다. LateUpdate에서 읽은 후 호출해주세요.
/// </summary>
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;
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d5db7d43fd93d24449614ea893fe209d