using System; using System.Collections.Generic; using UnityEngine; using UnityEngine.InputSystem; /// /// 창 전체를 기본적으로 클릭 통과 상태로 두고, OS 커서가 캐릭터 콜라이더 위에 /// 있을 때만 통과를 해제해 클릭을 받는다. /// /// Unity 의 Input 은 창 밖 좌표를 모르므로 GetCursorPos 로 OS 커서를 직접 읽어 /// 창 기준 좌표로 변환한 뒤 레이캐스트한다. /// /// 최상위 창의 클릭 통과에는 WS_EX_TRANSPARENT 만으로는 부족하고 WS_EX_LAYERED 가 /// 반드시 함께 필요하다(실측 확인). LAYERED 는 DwmExtendFrameIntoClientArea 기반 /// 투명도와 공존하며, SetLayeredWindowAttributes 호출은 필요하지 않았다. /// /// LAYERED 는 init 시 한 번만 걸고, 매 프레임 토글하는 것은 TRANSPARENT 뿐이다. /// /// 캐릭터 말고도 클릭을 받아야 하는 것이 생기면(채팅창 같은 UI) 그 영역을 /// RegisterInteractiveRegion 으로 등록한다. 히트테스트는 3D 콜라이더와 등록된 /// 영역을 모두 본다. /// [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 가 클릭을 받을 수 있을지 우려했으나, 실측 결과 클릭은 // 정상 수신된다. 사용자가 쓰던 앱의 포커스를 뺏지 않는 쪽이 데스크톱 비서로서 // 명백히 낫기 때문에 항상 켠다. // // 예외는 채팅창이다. 키보드 입력은 포커스가 없으면 들어오지 않으므로 // 채팅창이 열려 있는 동안만 SetNoActivate(false) 로 잠시 내린다. // // SerializeField 가 아닌 이유: 씬에 예전 false 값이 직렬화돼 있어 // 인스펙터 값이 코드 기본값을 이겨버린다. 런타임 토글은 Ctrl+Alt+N 으로 유지. bool useNoActivate = true; [Header("검증용")] [SerializeField] bool showDebugHud = false; [Tooltip("커서가 캐릭터 위에 있을 때 미세하게 커지는 반응. 과하면 화면 제한 계산에도 영향을 준다")] [SerializeField] bool visualizeHover = true; [Tooltip("호버 시 확대 배율. 1.03 정도가 자연스럽다. 값이 크면 드래그 시 화면 가장자리까지 못 간다")] [Range(1f, 1.2f)] [SerializeField] float hoverScale = 1.03f; #pragma warning restore 0414 TransparentWindow window; Transform hovered; Vector3 hoveredBaseScale; GUIStyle hudStyle; /// 캐릭터 외에 클릭을 받아야 하는 화면 영역들. 채팅창 등이 등록한다. readonly List> interactiveRegions = new List>(); /// 현재 OS 커서가 캐릭터 위에 있는지. public bool IsOverCharacter { get; private set; } /// 현재 OS 커서가 등록된 UI 영역 위에 있는지. public bool IsOverUi { get; private set; } /// /// true 인 동안 커서 위치와 무관하게 클릭을 받는다. /// /// 드래그 중에는 커서가 캐릭터를 벗어나기 마련인데, 그때 클릭 통과가 다시 켜지면 /// 마우스 입력이 뒤 창으로 새면서 드래그가 끊긴다. 드래그 같은 연속 조작은 /// 이 스위치로 히트테스트를 잠시 무력화한다. /// public bool ForceInteractive { get; set; } int clickCount; // Windows 빌드에서만 갱신된다. 에디터에서는 0 으로 남으므로 명시적으로 초기화해 // "한 번도 대입되지 않았다"는 경고를 없앤다. uint currentExStyle = 0; string lastAction = "-"; void Awake() { window = GetComponent(); if (hitTestCamera == null) hitTestCamera = GetComponent(); if (hitTestCamera == null) hitTestCamera = Camera.main; } /// /// 화면 좌표를 받아 "여기는 클릭을 받아야 한다"를 판정하는 함수를 등록한다. /// 등록한 쪽이 사라질 때 반드시 해제한다. /// public void RegisterInteractiveRegion(Func region) { if (region != null && !interactiveRegions.Contains(region)) interactiveRegions.Add(region); } public void UnregisterInteractiveRegion(Func region) { if (region != null) interactiveRegions.Remove(region); } /// /// NOACTIVATE 를 켜고 끈다. 끄면 창이 포커스를 받을 수 있게 되어 키보드 입력이 들어온다. /// 채팅창이 열고 닫을 때 부른다. /// public void SetNoActivate(bool enable) { if (useNoActivate == enable) return; useNoActivate = enable; lastAction = $"NOACTIVATE = {useNoActivate}"; #if !UNITY_EDITOR && UNITY_STANDALONE_WIN if (window != null && window.IsReady && window.Hwnd != IntPtr.Zero) ApplyBaseStyle(); #endif } void Update() { IntPtr hwnd = window != null ? window.Hwnd : IntPtr.Zero; // 좌표 변환은 DesktopCursor 에 모아뒀다. 시선 추적도 같은 변환을 쓴다. // 에디터에서는 일반 마우스 위치로 대체되므로 플레이 모드에서도 히트테스트가 돈다. bool haveCursor = DesktopCursor.TryGetScreenPosition(hwnd, out Vector2 cursor); // UI 가 캐릭터를 가리고 있으면 UI 가 이긴다. 채팅창 위에서 드래그가 시작되면 곤란하다. IsOverUi = haveCursor && IsInsideInteractiveRegion(cursor); RaycastHit hit = default; IsOverCharacter = haveCursor && !IsOverUi && HitTest(cursor, out hit); // 드래그 중에는 커서가 캐릭터를 벗어나도 호버 표시를 유지한다. // 화면 끝까지 끌면 캐릭터는 제한에 걸려 멈추고 커서만 더 나가는데, // 그때 크기가 원래대로 돌아가면 손에서 놓친 것처럼 보인다. Transform hoverTarget = IsOverCharacter ? hit.transform : (ForceInteractive ? hovered : null); UpdateHoverVisual(hoverTarget); var mouse = Mouse.current; if (IsOverCharacter && mouse != null && mouse.leftButton.wasPressedThisFrame) { clickCount++; lastAction = $"클릭: {hit.transform.name}"; } #if !UNITY_EDITOR && UNITY_STANDALONE_WIN UpdateWindowStyle(); #endif } bool IsInsideInteractiveRegion(Vector2 screenPos) { for (int i = 0; i < interactiveRegions.Count; i++) { var region = interactiveRegions[i]; if (region != null && region(screenPos)) return true; } return false; } bool HitTest(Vector2 screenPos, out RaycastHit hit) { hit = default; if (hitTestCamera == null) return false; var ray = hitTestCamera.ScreenPointToRay(new Vector3(screenPos.x, screenPos.y, 0f)); return Physics.Raycast(ray, out hit, maxRayDistance, interactableLayers); } #if !UNITY_EDITOR && UNITY_STANDALONE_WIN // true = 클릭이 뒤 창으로 통과하는 상태 bool clickThrough = true; bool styleInitialized; bool prevN; void UpdateWindowStyle() { if (!window.IsReady || window.Hwnd == IntPtr.Zero) return; if (!styleInitialized) { ApplyBaseStyle(); styleInitialized = true; } PollNoActivateToggle(); SetClickThrough(!IsOverCharacter && !IsOverUi && !ForceInteractive); currentExStyle = Win32.GetWindowLongW(window.Hwnd, Win32.GWL_EXSTYLE); } /// Ctrl+Alt+N 으로 NOACTIVATE 를 켜고 끈다. 재빌드 없이 클릭 수신 여부를 비교하기 위함. 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) { SetNoActivate(!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); } 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} OverUI: {IsOverUi} 클릭 횟수: {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); } }