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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:57:59 +09:00

436 lines
14 KiB
C#

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;
}
}