201 lines
8.1 KiB
C#
201 lines
8.1 KiB
C#
using UnityEngine;
|
|
|
|
/// <summary>
|
|
/// 캐릭터를 마우스로 집어 옮긴다.
|
|
///
|
|
/// 구현 노트 1 — 왜 Unity Input 을 쓰지 않는가:
|
|
/// 우리 창은 WS_EX_NOACTIVATE 라 포커스를 받지 않는다. 클릭 자체는 들어오지만
|
|
/// 포커스 없는 상태의 입력은 신뢰도가 떨어지고, 커서가 창 밖으로 나가면 Unity 는
|
|
/// 위치를 아예 모른다. GetAsyncKeyState 와 GetCursorPos 로 OS 에 직접 물으면
|
|
/// 포커스와 무관하게 항상 정확하다.
|
|
///
|
|
/// 구현 노트 2 — 드래그 중 클릭 통과 잠금:
|
|
/// 커서를 빠르게 움직이면 캐릭터 콜라이더를 벗어나는데, 그 순간 히트테스트가
|
|
/// 클릭 통과를 다시 켜버리면 입력이 뒤 창으로 새면서 드래그가 끊긴다.
|
|
/// 드래그 동안 ForceInteractive 로 히트테스트를 무력화한다.
|
|
///
|
|
/// 여기서 만든 "화면 좌표 -> 월드 위치" 배선은 창 올라타기에서도 그대로 쓴다.
|
|
/// </summary>
|
|
[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;
|
|
|
|
/// <summary>드래그 중인지. 창 올라타기 등 자동 이동 로직이 이 동안 양보한다.</summary>
|
|
public bool IsDragging => dragging;
|
|
|
|
/// <summary>드래그가 끝난 순간 호출. 창 올라타기가 착지 지점을 다시 계산할 때 쓴다.</summary>
|
|
public event System.Action<Transform> DragEnded;
|
|
|
|
/// <summary>
|
|
/// 캐릭터를 끌지 않고 그냥 눌렀다 뗀 순간 호출. 채팅창 토글이 이걸 쓴다.
|
|
///
|
|
/// 클릭과 드래그의 구분은 이미 dragThresholdPixels 로 하고 있으므로 판정을
|
|
/// 새로 만들지 않고 여기에 얹는다. 조금이라도 끌었으면 클릭이 아니다.
|
|
/// </summary>
|
|
public event System.Action Clicked;
|
|
|
|
/// <summary>
|
|
/// 캐릭터 위에서 오른쪽 버튼을 누른 순간. 포즈 바꾸기 등에 쓴다.
|
|
///
|
|
/// 왼쪽 버튼과 같은 방식으로 OS 에 직접 묻는다. 이 창은 포커스를 받지 않아
|
|
/// Unity 입력을 신뢰하기 어렵고, 무엇보다 판정에 쓰는 커서 좌표를 이미
|
|
/// GetCursorPos 로 얻고 있어 두 경로를 섞을 이유가 없다.
|
|
/// </summary>
|
|
public event System.Action RightClicked;
|
|
|
|
void Awake()
|
|
{
|
|
hitTest = GetComponent<ClickThroughHitTest>();
|
|
if (viewCamera == null) viewCamera = GetComponent<Camera>();
|
|
if (viewCamera == null) viewCamera = Camera.main;
|
|
if (window == null) window = FindFirstObjectByType<TransparentWindow>();
|
|
if (loader == null) loader = FindFirstObjectByType<VrmCharacterLoader>();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>화면 좌표를 드래그 평면(고정 깊이) 위의 월드 좌표로 변환한다.</summary>
|
|
Vector3 ScreenToWorld(Vector2 screenPos)
|
|
{
|
|
return viewCamera.ScreenToWorldPoint(new Vector3(screenPos.x, screenPos.y, dragDepth));
|
|
}
|
|
}
|