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