게임패드(Xbox/PS)를 이용한 6DOF 드론 카메라 자유비행 모드 추가 - GamepadInputHandler: 게임패드 입력 처리 (스틱, 트리거, 버튼, D-pad) - DroneCameraMode: 관성 기반 드론 물리 시뮬레이션 및 타겟 자동추적 - CameraController: 드론 모드 토글, 프리셋별 드론 상태 저장/복원 - SystemController: 아바타 Head 콜라이더 자동 생성 및 관리 - StreamDeckServerManager: 드론 모드 WebSocket 연동 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
436 lines
14 KiB
C#
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;
|
|
}
|
|
}
|