using UnityEngine;
///
/// 캐릭터를 마우스로 집어 옮긴다.
///
/// 구현 노트 1 — 왜 Unity Input 을 쓰지 않는가:
/// 우리 창은 WS_EX_NOACTIVATE 라 포커스를 받지 않는다. 클릭 자체는 들어오지만
/// 포커스 없는 상태의 입력은 신뢰도가 떨어지고, 커서가 창 밖으로 나가면 Unity 는
/// 위치를 아예 모른다. GetAsyncKeyState 와 GetCursorPos 로 OS 에 직접 물으면
/// 포커스와 무관하게 항상 정확하다.
///
/// 구현 노트 2 — 드래그 중 클릭 통과 잠금:
/// 커서를 빠르게 움직이면 캐릭터 콜라이더를 벗어나는데, 그 순간 히트테스트가
/// 클릭 통과를 다시 켜버리면 입력이 뒤 창으로 새면서 드래그가 끊긴다.
/// 드래그 동안 ForceInteractive 로 히트테스트를 무력화한다.
///
/// 여기서 만든 "화면 좌표 -> 월드 위치" 배선은 창 올라타기에서도 그대로 쓴다.
///
[RequireComponent(typeof(ClickThroughHitTest))]
public class CharacterDragger : MonoBehaviour
{
[Header("참조")]
[Tooltip("비우면 같은 오브젝트의 Camera 또는 Camera.main")]
[SerializeField] Camera viewCamera;
[Tooltip("비우면 씬에서 탐색")]
[SerializeField] TransparentWindow window;
[Tooltip("비우면 씬에서 탐색")]
[SerializeField] VrmCharacterLoader loader;
[Header("동작")]
[Tooltip("0 이면 커서에 즉시 붙는다. 값을 올리면 살짝 끌려오는 느낌이 난다")]
[Range(0f, 30f)]
[SerializeField] float followSpeed = 0f;
[Tooltip("이 거리 이상 움직여야 드래그로 인정한다. 클릭과 드래그를 구분한다")]
[SerializeField] float dragThresholdPixels = 4f;
[Tooltip("화면 가장자리에서 추가로 띄울 여백(픽셀). 캐릭터가 잘리지 않도록 제한한다")]
[SerializeField] float screenMargin = 0f;
ClickThroughHitTest hitTest;
Transform target;
bool pressing; // 버튼이 눌린 상태(아직 드래그는 아닐 수 있음)
bool dragging; // 임계값을 넘겨 실제 드래그 중
bool prevButtonDown; // 눌린 순간만 잡아내기 위한 직전 프레임 상태
bool prevRightDown;
Vector2 pressStartPos;
Vector2 grabScreenOffset; // 캐릭터 원점 - 커서 (화면 좌표)
Vector2 boundsOffMin, boundsOffMax; // 원점 기준 캐릭터의 화면상 범위
float dragDepth;
/// 드래그 중인지. 창 올라타기 등 자동 이동 로직이 이 동안 양보한다.
public bool IsDragging => dragging;
/// 드래그가 끝난 순간 호출. 창 올라타기가 착지 지점을 다시 계산할 때 쓴다.
public event System.Action DragEnded;
///
/// 캐릭터를 끌지 않고 그냥 눌렀다 뗀 순간 호출. 채팅창 토글이 이걸 쓴다.
///
/// 클릭과 드래그의 구분은 이미 dragThresholdPixels 로 하고 있으므로 판정을
/// 새로 만들지 않고 여기에 얹는다. 조금이라도 끌었으면 클릭이 아니다.
///
public event System.Action Clicked;
///
/// 캐릭터 위에서 오른쪽 버튼을 누른 순간. 포즈 바꾸기 등에 쓴다.
///
/// 왼쪽 버튼과 같은 방식으로 OS 에 직접 묻는다. 이 창은 포커스를 받지 않아
/// Unity 입력을 신뢰하기 어렵고, 무엇보다 판정에 쓰는 커서 좌표를 이미
/// GetCursorPos 로 얻고 있어 두 경로를 섞을 이유가 없다.
///
public event System.Action RightClicked;
void Awake()
{
hitTest = GetComponent();
if (viewCamera == null) viewCamera = GetComponent();
if (viewCamera == null) viewCamera = Camera.main;
if (window == null) window = FindFirstObjectByType();
if (loader == null) loader = FindFirstObjectByType();
}
void Update()
{
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
bool buttonDown = Win32.IsKeyDown(Win32.VK_LBUTTON);
bool rightDown = Win32.IsKeyDown(Win32.VK_RBUTTON);
#else
var mouse = UnityEngine.InputSystem.Mouse.current;
bool buttonDown = mouse != null && mouse.leftButton.isPressed;
bool rightDown = mouse != null && mouse.rightButton.isPressed;
#endif
// 오른쪽 버튼은 누른 순간만 본다. 드래그와 달리 이어지는 조작이 없다.
if (rightDown && !prevRightDown && hitTest.IsOverCharacter) RightClicked?.Invoke();
prevRightDown = rightDown;
// 눌린 "순간"만 잡는다. 이미 누른 채로 커서가 캐릭터 위로 지나가는 경우
// (다른 창을 끌고 오다가 캐릭터를 스치는 등)에 잡히면 안 된다.
// 클릭으로 채팅창이 토글되면서부터는 이 오작동이 눈에 띄게 된다.
bool justPressed = buttonDown && !prevButtonDown;
prevButtonDown = buttonDown;
if (!pressing && justPressed && hitTest.IsOverCharacter)
{
BeginPress();
}
else if (pressing && !buttonDown)
{
EndPress();
}
if (pressing) UpdatePress();
}
void BeginPress()
{
target = loader != null && loader.Current != null ? loader.Current.Root : null;
if (target == null) return;
if (!TryGetCursor(out Vector2 sp)) return;
pressing = true;
dragging = false;
pressStartPos = sp;
// 캐릭터가 놓인 깊이를 유지한다. 이 평면 위에서만 움직이므로 크기가 변하지 않는다.
dragDepth = Vector3.Dot(target.position - viewCamera.transform.position,
viewCamera.transform.forward);
// 깊이가 고정이라 화면상 크기도 고정이다. 시작할 때 한 번만 재면 된다.
if (!CharacterScreenBounds.TryMeasure(viewCamera, target, out boundsOffMin, out boundsOffMax))
{
boundsOffMin = boundsOffMax = Vector2.zero;
}
Vector2 originScreen = viewCamera.WorldToScreenPoint(target.position);
grabScreenOffset = originScreen - sp;
}
void UpdatePress()
{
if (target == null) { EndPress(); return; }
if (!TryGetCursor(out Vector2 sp)) return;
if (!dragging)
{
// 임계값을 넘기 전까지는 클릭으로 본다. 제자리 클릭에 캐릭터가 튀지 않게.
if (Vector2.Distance(sp, pressStartPos) < dragThresholdPixels) return;
dragging = true;
hitTest.ForceInteractive = true;
}
// 화면 좌표에서 제한한다. 월드에서 제한하면 카메라 각도에 따라 계산이 복잡해진다.
Vector2 desiredScreen = sp + grabScreenOffset;
desiredScreen = CharacterScreenBounds.Clamp(desiredScreen, boundsOffMin, boundsOffMax, screenMargin);
Vector3 desired = ScreenToWorld(desiredScreen);
if (followSpeed <= 0.01f)
{
target.position = desired;
}
else
{
float t = 1f - Mathf.Exp(-followSpeed * Time.deltaTime); // 프레임레이트 독립
target.position = Vector3.Lerp(target.position, desired, t);
}
}
void EndPress()
{
bool wasDragging = dragging;
Transform dragged = target;
pressing = false;
dragging = false;
hitTest.ForceInteractive = false;
target = null;
if (wasDragging && dragged != null) DragEnded?.Invoke(dragged);
else if (!wasDragging && dragged != null) Clicked?.Invoke();
}
bool TryGetCursor(out Vector2 screenPos)
{
System.IntPtr hwnd = window != null ? window.Hwnd : System.IntPtr.Zero;
return DesktopCursor.TryGetScreenPosition(hwnd, out screenPos);
}
/// 화면 좌표를 드래그 평면(고정 깊이) 위의 월드 좌표로 변환한다.
Vector3 ScreenToWorld(Vector2 screenPos)
{
return viewCamera.ScreenToWorldPoint(new Vector3(screenPos.x, screenPos.y, dragDepth));
}
}