216 lines
7.5 KiB
C#
216 lines
7.5 KiB
C#
using UnityEngine;
|
|
using UnityEngine.InputSystem;
|
|
|
|
/// <summary>
|
|
/// 창 전체를 기본적으로 클릭 통과 상태로 두고, OS 커서가 캐릭터 콜라이더 위에
|
|
/// 있을 때만 통과를 해제해 클릭을 받는다.
|
|
///
|
|
/// Unity 의 Input 은 창 밖 좌표를 모르므로 GetCursorPos 로 OS 커서를 직접 읽어
|
|
/// 창 기준 좌표로 변환한 뒤 레이캐스트한다.
|
|
///
|
|
/// 최상위 창의 클릭 통과에는 WS_EX_TRANSPARENT 만으로는 부족하고 WS_EX_LAYERED 가
|
|
/// 반드시 함께 필요하다(실측 확인). LAYERED 는 DwmExtendFrameIntoClientArea 기반
|
|
/// 투명도와 공존하며, SetLayeredWindowAttributes 호출은 필요하지 않았다.
|
|
///
|
|
/// LAYERED 는 init 시 한 번만 걸고, 매 프레임 토글하는 것은 TRANSPARENT 뿐이다.
|
|
/// </summary>
|
|
[RequireComponent(typeof(TransparentWindow))]
|
|
public class ClickThroughHitTest : MonoBehaviour
|
|
{
|
|
// 아래 필드들은 Windows 빌드에서만 쓰이므로 에디터 컴파일 시 CS0414 가 뜬다
|
|
#pragma warning disable 0414
|
|
|
|
[Header("히트테스트")]
|
|
[Tooltip("비우면 같은 오브젝트의 Camera 를 사용")]
|
|
[SerializeField] Camera hitTestCamera;
|
|
|
|
[SerializeField] LayerMask interactableLayers = ~0;
|
|
[SerializeField] float maxRayDistance = 500f;
|
|
|
|
// WS_EX_NOACTIVATE: 클릭해도 창이 포커스를 가져가지 않는다.
|
|
// 포커스 없이 Unity 가 클릭을 받을 수 있을지 우려했으나, 실측 결과 클릭은
|
|
// 정상 수신된다. 사용자가 쓰던 앱의 포커스를 뺏지 않는 쪽이 데스크톱 비서로서
|
|
// 명백히 낫기 때문에 항상 켠다.
|
|
//
|
|
// SerializeField 가 아닌 이유: 씬에 예전 false 값이 직렬화돼 있어
|
|
// 인스펙터 값이 코드 기본값을 이겨버린다. 런타임 토글은 Ctrl+Alt+N 으로 유지.
|
|
bool useNoActivate = true;
|
|
|
|
[Header("검증용")]
|
|
[SerializeField] bool showDebugHud = true;
|
|
|
|
[Tooltip("커서가 캐릭터 위에 있으면 살짝 커지게 해서 히트테스트를 눈으로 확인")]
|
|
[SerializeField] bool visualizeHover = true;
|
|
[SerializeField] float hoverScale = 1.15f;
|
|
|
|
#pragma warning restore 0414
|
|
|
|
TransparentWindow window;
|
|
Transform hovered;
|
|
Vector3 hoveredBaseScale;
|
|
GUIStyle hudStyle;
|
|
|
|
/// <summary>현재 OS 커서가 캐릭터 위에 있는지.</summary>
|
|
public bool IsOverCharacter { get; private set; }
|
|
|
|
int clickCount;
|
|
uint currentExStyle;
|
|
string lastAction = "-";
|
|
|
|
void Awake()
|
|
{
|
|
window = GetComponent<TransparentWindow>();
|
|
if (hitTestCamera == null) hitTestCamera = GetComponent<Camera>();
|
|
if (hitTestCamera == null) hitTestCamera = Camera.main;
|
|
}
|
|
|
|
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
|
|
// true = 클릭이 뒤 창으로 통과하는 상태
|
|
bool clickThrough = true;
|
|
bool styleInitialized;
|
|
bool prevN;
|
|
|
|
void Update()
|
|
{
|
|
if (!window.IsReady || window.Hwnd == System.IntPtr.Zero) return;
|
|
|
|
if (!styleInitialized)
|
|
{
|
|
ApplyBaseStyle();
|
|
styleInitialized = true;
|
|
}
|
|
|
|
PollNoActivateToggle();
|
|
|
|
IsOverCharacter = HitTest(out var hit);
|
|
SetClickThrough(!IsOverCharacter);
|
|
UpdateHoverVisual(IsOverCharacter ? hit.transform : null);
|
|
|
|
var mouse = Mouse.current;
|
|
if (IsOverCharacter && mouse != null && mouse.leftButton.wasPressedThisFrame)
|
|
{
|
|
clickCount++;
|
|
lastAction = $"클릭: {hit.transform.name}";
|
|
Debug.Log($"[ClickThroughHitTest] 캐릭터 클릭됨: {hit.transform.name}");
|
|
}
|
|
|
|
currentExStyle = Win32.GetWindowLongW(window.Hwnd, Win32.GWL_EXSTYLE);
|
|
}
|
|
|
|
/// <summary>Ctrl+Alt+N 으로 NOACTIVATE 를 켜고 끈다. 재빌드 없이 클릭 수신 여부를 비교하기 위함.</summary>
|
|
void PollNoActivateToggle()
|
|
{
|
|
if (!Win32.IsKeyDown(Win32.VK_CONTROL) || !Win32.IsKeyDown(Win32.VK_MENU))
|
|
{
|
|
prevN = false;
|
|
return;
|
|
}
|
|
|
|
bool now = Win32.IsKeyDown(Win32.VK_N);
|
|
if (now && !prevN)
|
|
{
|
|
useNoActivate = !useNoActivate;
|
|
ApplyBaseStyle();
|
|
lastAction = $"NOACTIVATE = {useNoActivate}";
|
|
Debug.Log($"[ClickThroughHitTest] NOACTIVATE = {useNoActivate}");
|
|
}
|
|
prevN = now;
|
|
}
|
|
|
|
void ApplyBaseStyle()
|
|
{
|
|
uint ex = Win32.GetWindowLongW(window.Hwnd, Win32.GWL_EXSTYLE);
|
|
|
|
ex |= Win32.WS_EX_LAYERED; // 클릭 통과의 전제. 한 번만 걸고 유지한다.
|
|
|
|
if (useNoActivate) ex |= Win32.WS_EX_NOACTIVATE;
|
|
else ex &= ~Win32.WS_EX_NOACTIVATE;
|
|
|
|
if (clickThrough) ex |= Win32.WS_EX_TRANSPARENT;
|
|
else ex &= ~Win32.WS_EX_TRANSPARENT;
|
|
|
|
Win32.SetWindowLongW(window.Hwnd, Win32.GWL_EXSTYLE, ex);
|
|
|
|
Win32.SetWindowPos(window.Hwnd, Win32.HWND_TOPMOST, 0, 0, 0, 0,
|
|
Win32.SWP_NOMOVE | Win32.SWP_NOSIZE | Win32.SWP_NOACTIVATE | Win32.SWP_FRAMECHANGED);
|
|
}
|
|
|
|
bool HitTest(out RaycastHit hit)
|
|
{
|
|
hit = default;
|
|
if (hitTestCamera == null) return false;
|
|
if (!Win32.GetCursorPos(out var pt)) return false;
|
|
if (!Win32.GetWindowRect(window.Hwnd, out var rc)) return false;
|
|
|
|
int w = rc.Width, h = rc.Height;
|
|
if (w <= 0 || h <= 0) return false;
|
|
|
|
// 창 기준 좌표 (좌상단 원점, Y 아래로)
|
|
int localX = pt.x - rc.left;
|
|
int localY = pt.y - rc.top;
|
|
if (localX < 0 || localY < 0 || localX >= w || localY >= h) return false;
|
|
|
|
// Unity 화면 좌표 (좌하단 원점, Y 위로)
|
|
float ux = localX * (Screen.width / (float)w);
|
|
float uy = (h - localY) * (Screen.height / (float)h);
|
|
|
|
var ray = hitTestCamera.ScreenPointToRay(new Vector3(ux, uy, 0f));
|
|
return Physics.Raycast(ray, out hit, maxRayDistance, interactableLayers);
|
|
}
|
|
|
|
void SetClickThrough(bool enable)
|
|
{
|
|
if (enable == clickThrough) return;
|
|
clickThrough = enable;
|
|
|
|
uint ex = Win32.GetWindowLongW(window.Hwnd, Win32.GWL_EXSTYLE);
|
|
if (enable) ex |= Win32.WS_EX_TRANSPARENT;
|
|
else ex &= ~Win32.WS_EX_TRANSPARENT;
|
|
Win32.SetWindowLongW(window.Hwnd, Win32.GWL_EXSTYLE, ex);
|
|
}
|
|
#endif
|
|
|
|
void UpdateHoverVisual(Transform target)
|
|
{
|
|
if (!visualizeHover) return;
|
|
if (hovered == target) return;
|
|
|
|
if (hovered != null) hovered.localScale = hoveredBaseScale;
|
|
|
|
hovered = target;
|
|
if (hovered != null)
|
|
{
|
|
hoveredBaseScale = hovered.localScale;
|
|
hovered.localScale = hoveredBaseScale * hoverScale;
|
|
}
|
|
}
|
|
|
|
void OnGUI()
|
|
{
|
|
if (!showDebugHud) return;
|
|
|
|
if (hudStyle == null)
|
|
{
|
|
hudStyle = new GUIStyle(GUI.skin.label) { fontSize = 16, richText = false };
|
|
}
|
|
|
|
bool layered = (currentExStyle & 0x00080000) != 0;
|
|
bool transparent = (currentExStyle & 0x00000020) != 0;
|
|
|
|
string text =
|
|
$"NOACTIVATE: {useNoActivate} [Ctrl+Alt+N 전환]\n" +
|
|
$"ExStyle: 0x{currentExStyle:X8} LAYERED={layered} TRANSPARENT={transparent}\n" +
|
|
$"OverCharacter: {IsOverCharacter} 클릭 횟수: {clickCount}\n" +
|
|
$"마지막: {lastAction}\n" +
|
|
$"[Ctrl+Alt+Q 종료]";
|
|
|
|
var rect = new Rect(14f, 14f, 900f, 160f);
|
|
|
|
// 배경 없이도 읽히도록 그림자를 먼저 깐다
|
|
hudStyle.normal.textColor = Color.black;
|
|
GUI.Label(new Rect(rect.x + 1f, rect.y + 1f, rect.width, rect.height), text, hudStyle);
|
|
hudStyle.normal.textColor = Color.white;
|
|
GUI.Label(rect, text, hudStyle);
|
|
}
|
|
}
|