first commit
This commit is contained in:
215
Assets/02_Scripts/Desktop/ClickThroughHitTest.cs
Normal file
215
Assets/02_Scripts/Desktop/ClickThroughHitTest.cs
Normal file
@@ -0,0 +1,215 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Desktop/ClickThroughHitTest.cs.meta
Normal file
2
Assets/02_Scripts/Desktop/ClickThroughHitTest.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5ca4ab0faf560dd4f8fcf2d3093dceb7
|
||||
40
Assets/02_Scripts/Desktop/GlobalExitHotkey.cs
Normal file
40
Assets/02_Scripts/Desktop/GlobalExitHotkey.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// 전역 종료 핫키. 기본 Ctrl+Alt+Q.
|
||||
///
|
||||
/// 작업표시줄에서 숨겨진 데다 WS_EX_NOACTIVATE 까지 붙으면 창이 포커스를
|
||||
/// 받지 못해 Unity 의 키 입력이 들어오지 않는다. GetAsyncKeyState 는 포커스와
|
||||
/// 무관하게 전역 키 상태를 읽으므로, 메시지 루프나 wndproc 후킹 없이
|
||||
/// 확실한 탈출구가 된다.
|
||||
/// </summary>
|
||||
public class GlobalExitHotkey : MonoBehaviour
|
||||
{
|
||||
// 아래 필드들은 Windows 빌드에서만 쓰이므로 에디터 컴파일 시 CS0414 가 뜬다
|
||||
#pragma warning disable 0414
|
||||
|
||||
[Tooltip("A-Z 한 글자. Ctrl+Alt 와 조합된다")]
|
||||
[SerializeField] char exitKey = 'Q';
|
||||
|
||||
[SerializeField] bool requireCtrl = true;
|
||||
[SerializeField] bool requireAlt = true;
|
||||
|
||||
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
|
||||
bool quitting;
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (quitting) return;
|
||||
|
||||
if (requireCtrl && !Win32.IsKeyDown(Win32.VK_CONTROL)) return;
|
||||
if (requireAlt && !Win32.IsKeyDown(Win32.VK_MENU)) return;
|
||||
|
||||
int vk = char.ToUpperInvariant(exitKey);
|
||||
if (!Win32.IsKeyDown(vk)) return;
|
||||
|
||||
quitting = true;
|
||||
Debug.Log("[GlobalExitHotkey] 종료 핫키 감지. 종료합니다.");
|
||||
Application.Quit();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
2
Assets/02_Scripts/Desktop/GlobalExitHotkey.cs.meta
Normal file
2
Assets/02_Scripts/Desktop/GlobalExitHotkey.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 83652a9ef9463bc4aabdd81cc62a3954
|
||||
148
Assets/02_Scripts/Desktop/TransparentWindow.cs
Normal file
148
Assets/02_Scripts/Desktop/TransparentWindow.cs
Normal file
@@ -0,0 +1,148 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.Rendering.Universal;
|
||||
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
|
||||
using System;
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// 창을 테두리 없는 최상단 투명 창으로 만든다.
|
||||
/// 작업표시줄과 Alt+Tab 에서도 숨긴다.
|
||||
/// 에디터에서는 아무것도 하지 않는다 (에디터 창이 사라지는 사고 방지).
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Camera))]
|
||||
public class TransparentWindow : MonoBehaviour
|
||||
{
|
||||
[Header("창 동작")]
|
||||
[Tooltip("창을 가상 데스크톱 전체로 확장. 클릭 통과가 동작하는 것을 확인한 뒤 켤 것")]
|
||||
[SerializeField] bool spanVirtualDesktop = false;
|
||||
|
||||
[Tooltip("작업표시줄 / Alt+Tab 에서 숨김 (WS_EX_TOOLWINDOW)")]
|
||||
[SerializeField] bool hideFromTaskbar = true;
|
||||
|
||||
[Header("검증용")]
|
||||
[Tooltip("런타임 큐브 생성. 씬에 AlphaTestCube 가 있으면 그쪽이 우선한다")]
|
||||
[SerializeField] bool spawnTestCube = false;
|
||||
|
||||
[Tooltip("URP 포스트프로세싱 비활성화")]
|
||||
[SerializeField] bool forceDisablePostProcessing = true;
|
||||
|
||||
[Tooltip("포커스가 있을 때 Esc 로 종료. NOACTIVATE 를 켜면 동작하지 않으므로 GlobalExitHotkey 를 함께 쓸 것")]
|
||||
[SerializeField] bool quitOnEscape = true;
|
||||
|
||||
Camera cam;
|
||||
Transform testCube;
|
||||
|
||||
/// <summary>플레이어 창 핸들. 준비되기 전에는 Zero.</summary>
|
||||
public System.IntPtr Hwnd { get; private set; } = System.IntPtr.Zero;
|
||||
|
||||
/// <summary>Win32 스타일 적용이 끝났는지.</summary>
|
||||
public bool IsReady { get; private set; }
|
||||
|
||||
void Awake()
|
||||
{
|
||||
cam = GetComponent<Camera>();
|
||||
|
||||
// 알파 0 클리어 — 투명의 출발점
|
||||
cam.clearFlags = CameraClearFlags.SolidColor;
|
||||
cam.backgroundColor = new Color(0f, 0f, 0f, 0f);
|
||||
|
||||
if (forceDisablePostProcessing)
|
||||
{
|
||||
var urp = cam.GetUniversalAdditionalCameraData();
|
||||
if (urp != null) urp.renderPostProcessing = false;
|
||||
}
|
||||
|
||||
Application.runInBackground = true;
|
||||
|
||||
// 씬에 큐브가 있으면 그것을 쓰고, 없을 때만 런타임 생성
|
||||
var inScene = GameObject.Find("AlphaTestCube");
|
||||
if (inScene != null) testCube = inScene.transform;
|
||||
else if (spawnTestCube) CreateTestCube();
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
|
||||
StartCoroutine(ApplyWindowStyle());
|
||||
#else
|
||||
Debug.Log("[TransparentWindow] 에디터에서는 Win32 적용을 건너뜁니다. 빌드해서 테스트하세요.");
|
||||
#endif
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (testCube != null) testCube.Rotate(new Vector3(30f, 45f, 15f) * Time.deltaTime);
|
||||
|
||||
// 포커스가 있을 때만 동작하는 보조 탈출구
|
||||
if (quitOnEscape)
|
||||
{
|
||||
var kb = Keyboard.current;
|
||||
if (kb != null && kb.escapeKey.wasPressedThisFrame) Application.Quit();
|
||||
}
|
||||
}
|
||||
|
||||
void CreateTestCube()
|
||||
{
|
||||
var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
|
||||
cube.name = "AlphaTestCube";
|
||||
cube.transform.position = transform.position + transform.forward * 5f;
|
||||
testCube = cube.transform;
|
||||
}
|
||||
|
||||
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
|
||||
IEnumerator ApplyWindowStyle()
|
||||
{
|
||||
int x = 0, y = 0, w = Screen.width, h = Screen.height;
|
||||
|
||||
if (spanVirtualDesktop)
|
||||
{
|
||||
x = Win32.GetSystemMetrics(Win32.SM_XVIRTUALSCREEN);
|
||||
y = Win32.GetSystemMetrics(Win32.SM_YVIRTUALSCREEN);
|
||||
w = Win32.GetSystemMetrics(Win32.SM_CXVIRTUALSCREEN);
|
||||
h = Win32.GetSystemMetrics(Win32.SM_CYVIRTUALSCREEN);
|
||||
Screen.SetResolution(w, h, FullScreenMode.Windowed);
|
||||
}
|
||||
|
||||
// SetResolution 은 프레임 끝에 반영되므로 스타일 적용 전에 기다린다
|
||||
yield return null;
|
||||
yield return new WaitForEndOfFrame();
|
||||
yield return null;
|
||||
|
||||
IntPtr hwnd = Win32.GetActiveWindow();
|
||||
if (hwnd == IntPtr.Zero) hwnd = Win32.FindWindowW(null, Application.productName);
|
||||
if (hwnd == IntPtr.Zero)
|
||||
{
|
||||
Debug.LogError("[TransparentWindow] HWND 를 찾지 못했습니다.");
|
||||
yield break;
|
||||
}
|
||||
|
||||
// 타이틀바 / 테두리 제거
|
||||
Win32.SetWindowLongW(hwnd, Win32.GWL_STYLE, Win32.WS_POPUP | Win32.WS_VISIBLE);
|
||||
|
||||
if (hideFromTaskbar)
|
||||
{
|
||||
uint ex = Win32.GetWindowLongW(hwnd, Win32.GWL_EXSTYLE);
|
||||
ex = (ex | Win32.WS_EX_TOOLWINDOW) & ~Win32.WS_EX_APPWINDOW;
|
||||
Win32.SetWindowLongW(hwnd, Win32.GWL_EXSTYLE, ex);
|
||||
|
||||
// TOOLWINDOW 변경은 hide/show 해야 작업표시줄에 반영된다
|
||||
Win32.ShowWindow(hwnd, Win32.SW_HIDE);
|
||||
Win32.ShowWindow(hwnd, Win32.SW_SHOW);
|
||||
}
|
||||
|
||||
// 픽셀 단위 알파 투명
|
||||
var margins = new Win32.MARGINS { cxLeftWidth = -1, cxRightWidth = -1, cyTopHeight = -1, cyBottomHeight = -1 };
|
||||
int hr = Win32.DwmExtendFrameIntoClientArea(hwnd, ref margins);
|
||||
Debug.Log($"[TransparentWindow] DwmExtendFrameIntoClientArea hr=0x{hr:X8} (0 이면 성공)");
|
||||
|
||||
Win32.SetWindowPos(hwnd, Win32.HWND_TOPMOST, x, y, w, h,
|
||||
Win32.SWP_FRAMECHANGED | Win32.SWP_SHOWWINDOW);
|
||||
|
||||
Hwnd = hwnd;
|
||||
IsReady = true;
|
||||
Debug.Log($"[TransparentWindow] 적용 완료: {x},{y} {w}x{h}");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
2
Assets/02_Scripts/Desktop/TransparentWindow.cs.meta
Normal file
2
Assets/02_Scripts/Desktop/TransparentWindow.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e3227c6d05a07a640bb1d96118c0a82e
|
||||
371
Assets/02_Scripts/Desktop/TrayIcon.cs
Normal file
371
Assets/02_Scripts/Desktop/TrayIcon.cs
Normal file
@@ -0,0 +1,371 @@
|
||||
using UnityEngine;
|
||||
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// 시스템 트레이 아이콘과 우클릭 메뉴.
|
||||
///
|
||||
/// WS_EX_TOOLWINDOW 로 작업표시줄에서 사라졌기 때문에 종료/제어 수단이 필요하다.
|
||||
///
|
||||
/// 구현 노트: Shell_NotifyIcon 은 콜백 메시지를 받을 창이 필요한데, Unity 창의
|
||||
/// 윈도우 프로시저를 SetWindowLongPtr 로 가로채는 것은 관리 델리게이트 수명과
|
||||
/// 재진입 문제가 있어 위험하다. 대신 전용 창을 따로 만들어 거기에 아이콘을 걸고,
|
||||
/// PeekMessage 를 그 창으로 한정해 직접 펌핑한다. Unity 의 메시지 루프는 건드리지 않는다.
|
||||
///
|
||||
/// HWND_MESSAGE(메시지 전용 창)를 쓰지 않는 이유: 메시지 전용 창은 브로드캐스트
|
||||
/// 메시지를 받지 못해 TaskbarCreated 재등록이 동작하지 않고, z-order 가 없어
|
||||
/// SetForegroundWindow 가 실패해 팝업 메뉴가 제대로 닫히지 않는다.
|
||||
/// 대신 WS_EX_TOOLWINDOW + WS_POPUP 인 일반 최상위 창을 만들되 표시하지 않는다.
|
||||
/// </summary>
|
||||
public class TrayIcon : MonoBehaviour
|
||||
{
|
||||
[SerializeField] string tooltip = "MyCharacterAgent";
|
||||
[SerializeField] bool verboseLog = true;
|
||||
|
||||
TransparentWindow window;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
window = GetComponent<TransparentWindow>();
|
||||
if (window == null) window = FindFirstObjectByType<TransparentWindow>();
|
||||
}
|
||||
|
||||
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
|
||||
|
||||
const string ClassName = "MyCharacterAgentTrayWnd";
|
||||
|
||||
const int ID_TOGGLE = 1;
|
||||
const int ID_EXIT = 2;
|
||||
|
||||
delegate IntPtr WndProcDelegate(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
// GC 가 델리게이트를 수거하면 네이티브 쪽에서 죽은 포인터를 호출하게 되므로
|
||||
// 반드시 static 필드로 붙잡아 둔다.
|
||||
static WndProcDelegate wndProcHolder;
|
||||
|
||||
IntPtr trayHwnd = IntPtr.Zero;
|
||||
bool iconAdded;
|
||||
bool characterVisible = true;
|
||||
uint taskbarCreatedMsg;
|
||||
bool cleaned;
|
||||
|
||||
// wndproc 에서 세우고 Update 에서 처리한다. TrackPopupMenu 는 중첩 메시지 루프를
|
||||
// 돌리므로 메시지 처리 도중이 아니라 프레임의 안전한 지점에서 부르는 편이 낫다.
|
||||
bool pendingMenu;
|
||||
bool pendingToggle;
|
||||
bool pendingReAdd;
|
||||
|
||||
void Start()
|
||||
{
|
||||
StartCoroutine(CreateWhenReady());
|
||||
}
|
||||
|
||||
System.Collections.IEnumerator CreateWhenReady()
|
||||
{
|
||||
// 보이기/숨기기가 Unity 창 핸들을 쓰므로 창 준비를 기다린다.
|
||||
float deadline = Time.realtimeSinceStartup + 5f;
|
||||
while ((window == null || !window.IsReady) && Time.realtimeSinceStartup < deadline)
|
||||
yield return null;
|
||||
|
||||
// 창 생성 전에 등록해야 wndproc 이 첫 브로드캐스트부터 인식할 수 있다.
|
||||
// (탐색기가 재시작되면 트레이 아이콘이 사라지므로 재등록이 필요하다)
|
||||
taskbarCreatedMsg = RegisterWindowMessageW("TaskbarCreated");
|
||||
|
||||
if (!CreateTrayWindow()) yield break;
|
||||
AddIcon();
|
||||
}
|
||||
|
||||
bool CreateTrayWindow()
|
||||
{
|
||||
IntPtr hInstance = GetModuleHandleW(null);
|
||||
|
||||
wndProcHolder = WndProc;
|
||||
|
||||
var wc = new WNDCLASSEXW
|
||||
{
|
||||
cbSize = Marshal.SizeOf(typeof(WNDCLASSEXW)),
|
||||
lpfnWndProc = Marshal.GetFunctionPointerForDelegate(wndProcHolder),
|
||||
hInstance = hInstance,
|
||||
lpszClassName = ClassName,
|
||||
};
|
||||
|
||||
// 이미 등록된 클래스여도 무시하고 창 생성을 시도한다.
|
||||
RegisterClassExW(ref wc);
|
||||
|
||||
// WS_VISIBLE 를 주지 않으므로 화면에 나타나지 않는다.
|
||||
// WS_EX_TOOLWINDOW 로 작업표시줄/Alt+Tab 에도 뜨지 않는다.
|
||||
trayHwnd = CreateWindowExW(Win32.WS_EX_TOOLWINDOW, ClassName, string.Empty,
|
||||
Win32.WS_POPUP, 0, 0, 0, 0,
|
||||
IntPtr.Zero, IntPtr.Zero, hInstance, IntPtr.Zero);
|
||||
|
||||
if (trayHwnd == IntPtr.Zero)
|
||||
{
|
||||
Debug.LogError($"[TrayIcon] 트레이용 창 생성 실패. err={Marshal.GetLastWin32Error()}");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 우리 창의 윈도우 프로시저. Unity 메인 스레드의 DispatchMessage 에서 호출된다.
|
||||
/// </summary>
|
||||
IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam)
|
||||
{
|
||||
if (msg == WM_TRAYICON)
|
||||
{
|
||||
uint ev = (uint)(lParam.ToInt64() & 0xFFFF);
|
||||
// 아이콘 위로 마우스가 지나가기만 해도 WM_MOUSEMOVE 가 계속 온다.
|
||||
// 상시 구동되는 앱이라 그대로 찍으면 로그가 끝없이 커진다.
|
||||
if (verboseLog && ev != WM_MOUSEMOVE)
|
||||
Debug.Log($"[TrayIcon] WM_TRAYICON ev=0x{ev:X4}");
|
||||
|
||||
if (ev == WM_RBUTTONUP || ev == WM_CONTEXTMENU) pendingMenu = true;
|
||||
else if (ev == WM_LBUTTONDBLCLK) pendingToggle = true;
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
if (taskbarCreatedMsg != 0 && msg == taskbarCreatedMsg)
|
||||
{
|
||||
pendingReAdd = true;
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
return DefWindowProcW(hWnd, msg, wParam, lParam);
|
||||
}
|
||||
|
||||
IntPtr LoadAppIcon()
|
||||
{
|
||||
try
|
||||
{
|
||||
string exe = System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName;
|
||||
IntPtr icon = ExtractIconW(GetModuleHandleW(null), exe, 0);
|
||||
if (icon != IntPtr.Zero && icon != new IntPtr(1)) return icon;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogWarning($"[TrayIcon] exe 아이콘 추출 실패, 기본 아이콘 사용: {e.Message}");
|
||||
}
|
||||
return LoadIconW(IntPtr.Zero, new IntPtr(IDI_APPLICATION));
|
||||
}
|
||||
|
||||
void AddIcon()
|
||||
{
|
||||
var data = new NOTIFYICONDATAW
|
||||
{
|
||||
cbSize = Marshal.SizeOf(typeof(NOTIFYICONDATAW)),
|
||||
hWnd = trayHwnd,
|
||||
uID = 1,
|
||||
uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP,
|
||||
uCallbackMessage = WM_TRAYICON,
|
||||
hIcon = LoadAppIcon(),
|
||||
szTip = tooltip,
|
||||
};
|
||||
|
||||
iconAdded = Shell_NotifyIconW(NIM_ADD, ref data);
|
||||
if (iconAdded) Debug.Log("[TrayIcon] 트레이 아이콘 등록됨");
|
||||
else Debug.LogError("[TrayIcon] Shell_NotifyIcon(NIM_ADD) 실패");
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (trayHwnd == IntPtr.Zero) return;
|
||||
|
||||
// Unity 펌프가 우리 창 메시지를 대부분 처리하지만, 놓치는 경우를 대비한 보조 펌프.
|
||||
// 처리는 전적으로 wndproc 이 하므로 여기서는 디스패치만 한다(이중 처리 방지).
|
||||
while (PeekMessageW(out MSG msg, trayHwnd, 0, 0, PM_REMOVE))
|
||||
{
|
||||
TranslateMessage(ref msg);
|
||||
DispatchMessageW(ref msg);
|
||||
}
|
||||
|
||||
if (pendingReAdd) { pendingReAdd = false; AddIcon(); }
|
||||
if (pendingToggle) { pendingToggle = false; ToggleCharacter(); }
|
||||
if (pendingMenu) { pendingMenu = false; ShowContextMenu(); }
|
||||
}
|
||||
|
||||
void ShowContextMenu()
|
||||
{
|
||||
IntPtr menu = CreatePopupMenu();
|
||||
if (menu == IntPtr.Zero) return;
|
||||
|
||||
AppendMenuW(menu, MF_STRING, ID_TOGGLE, characterVisible ? "캐릭터 숨기기" : "캐릭터 보이기");
|
||||
AppendMenuW(menu, MF_SEPARATOR, 0, null);
|
||||
AppendMenuW(menu, MF_STRING, ID_EXIT, "종료");
|
||||
|
||||
Win32.GetCursorPos(out var pt);
|
||||
|
||||
// 트레이 메뉴 관용구: 앞으로 가져와야 메뉴 밖 클릭 시 정상적으로 닫힌다.
|
||||
SetForegroundWindow(trayHwnd);
|
||||
|
||||
int cmd = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_RIGHTBUTTON | TPM_NONOTIFY,
|
||||
pt.x, pt.y, 0, trayHwnd, IntPtr.Zero);
|
||||
|
||||
PostMessageW(trayHwnd, WM_NULL, IntPtr.Zero, IntPtr.Zero);
|
||||
DestroyMenu(menu);
|
||||
|
||||
if (verboseLog) Debug.Log($"[TrayIcon] 메뉴 선택 결과 cmd={cmd}");
|
||||
|
||||
if (cmd == ID_EXIT)
|
||||
{
|
||||
Debug.Log("[TrayIcon] 메뉴에서 종료 선택");
|
||||
Application.Quit();
|
||||
}
|
||||
else if (cmd == ID_TOGGLE)
|
||||
{
|
||||
ToggleCharacter();
|
||||
}
|
||||
}
|
||||
|
||||
void ToggleCharacter()
|
||||
{
|
||||
if (window == null || !window.IsReady) return;
|
||||
|
||||
characterVisible = !characterVisible;
|
||||
Win32.ShowWindow(window.Hwnd, characterVisible ? Win32.SW_SHOW : Win32.SW_HIDE);
|
||||
|
||||
if (characterVisible)
|
||||
{
|
||||
// SW_SHOW 후 최상단 속성이 풀릴 수 있어 다시 걸어준다.
|
||||
Win32.SetWindowPos(window.Hwnd, Win32.HWND_TOPMOST, 0, 0, 0, 0,
|
||||
Win32.SWP_NOMOVE | Win32.SWP_NOSIZE | Win32.SWP_NOACTIVATE);
|
||||
}
|
||||
}
|
||||
|
||||
void RemoveIcon()
|
||||
{
|
||||
if (!iconAdded) return;
|
||||
|
||||
var data = new NOTIFYICONDATAW
|
||||
{
|
||||
cbSize = Marshal.SizeOf(typeof(NOTIFYICONDATAW)),
|
||||
hWnd = trayHwnd,
|
||||
uID = 1,
|
||||
};
|
||||
Shell_NotifyIconW(NIM_DELETE, ref data);
|
||||
iconAdded = false;
|
||||
}
|
||||
|
||||
// 정리를 빠뜨리면 종료 후에도 유령 아이콘이 트레이에 남는다.
|
||||
void OnApplicationQuit() => Cleanup();
|
||||
void OnDestroy() => Cleanup();
|
||||
|
||||
void Cleanup()
|
||||
{
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
|
||||
RemoveIcon();
|
||||
if (trayHwnd != IntPtr.Zero)
|
||||
{
|
||||
DestroyWindow(trayHwnd);
|
||||
trayHwnd = IntPtr.Zero;
|
||||
}
|
||||
UnregisterClassW(ClassName, GetModuleHandleW(null));
|
||||
wndProcHolder = null;
|
||||
}
|
||||
|
||||
// ---------------- P/Invoke (트레이 전용) ----------------
|
||||
|
||||
const int IDI_APPLICATION = 32512;
|
||||
|
||||
const uint NIM_ADD = 0x00000000;
|
||||
const uint NIM_DELETE = 0x00000002;
|
||||
const uint NIF_MESSAGE = 0x00000001;
|
||||
const uint NIF_ICON = 0x00000002;
|
||||
const uint NIF_TIP = 0x00000004;
|
||||
|
||||
const uint WM_NULL = 0x0000;
|
||||
const uint WM_MOUSEMOVE = 0x0200;
|
||||
const uint WM_LBUTTONDBLCLK = 0x0203;
|
||||
const uint WM_RBUTTONUP = 0x0205;
|
||||
const uint WM_CONTEXTMENU = 0x007B;
|
||||
const uint WM_APP = 0x8000;
|
||||
const uint WM_TRAYICON = WM_APP + 1;
|
||||
|
||||
const uint PM_REMOVE = 0x0001;
|
||||
|
||||
const uint MF_STRING = 0x0000;
|
||||
const uint MF_SEPARATOR = 0x0800;
|
||||
|
||||
const uint TPM_RIGHTBUTTON = 0x0002;
|
||||
const uint TPM_RETURNCMD = 0x0100;
|
||||
const uint TPM_NONOTIFY = 0x0080;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
struct WNDCLASSEXW
|
||||
{
|
||||
public int cbSize;
|
||||
public uint style;
|
||||
public IntPtr lpfnWndProc;
|
||||
public int cbClsExtra;
|
||||
public int cbWndExtra;
|
||||
public IntPtr hInstance;
|
||||
public IntPtr hIcon;
|
||||
public IntPtr hCursor;
|
||||
public IntPtr hbrBackground;
|
||||
[MarshalAs(UnmanagedType.LPWStr)] public string lpszMenuName;
|
||||
[MarshalAs(UnmanagedType.LPWStr)] public string lpszClassName;
|
||||
public IntPtr hIconSm;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct MSG
|
||||
{
|
||||
public IntPtr hwnd;
|
||||
public uint message;
|
||||
public IntPtr wParam;
|
||||
public IntPtr lParam;
|
||||
public uint time;
|
||||
public Win32.POINT pt;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
struct NOTIFYICONDATAW
|
||||
{
|
||||
public int cbSize;
|
||||
public IntPtr hWnd;
|
||||
public uint uID;
|
||||
public uint uFlags;
|
||||
public uint uCallbackMessage;
|
||||
public IntPtr hIcon;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string szTip;
|
||||
public uint dwState;
|
||||
public uint dwStateMask;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string szInfo;
|
||||
public uint uVersion;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] public string szInfoTitle;
|
||||
public uint dwInfoFlags;
|
||||
public Guid guidItem;
|
||||
public IntPtr hBalloonIcon;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)] static extern IntPtr GetModuleHandleW(string lpModuleName);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] static extern ushort RegisterClassExW(ref WNDCLASSEXW lpwcx);
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] static extern bool UnregisterClassW(string lpClassName, IntPtr hInstance);
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
static extern IntPtr CreateWindowExW(uint dwExStyle, string lpClassName, string lpWindowName,
|
||||
uint dwStyle, int x, int y, int nWidth, int nHeight,
|
||||
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
|
||||
[DllImport("user32.dll")] static extern bool DestroyWindow(IntPtr hWnd);
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern IntPtr DefWindowProcW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern bool PeekMessageW(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg);
|
||||
[DllImport("user32.dll")] static extern bool TranslateMessage(ref MSG lpMsg);
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern IntPtr DispatchMessageW(ref MSG lpMsg);
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern bool PostMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern uint RegisterWindowMessageW(string lpString);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern IntPtr LoadIconW(IntPtr hInstance, IntPtr lpIconName);
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)] static extern IntPtr ExtractIconW(IntPtr hInst, string lpszExeFileName, int nIconIndex);
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)] static extern bool Shell_NotifyIconW(uint dwMessage, ref NOTIFYICONDATAW lpData);
|
||||
|
||||
[DllImport("user32.dll")] static extern IntPtr CreatePopupMenu();
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern bool AppendMenuW(IntPtr hMenu, uint uFlags, int uIDNewItem, string lpNewItem);
|
||||
[DllImport("user32.dll")] static extern bool DestroyMenu(IntPtr hMenu);
|
||||
[DllImport("user32.dll")] static extern int TrackPopupMenu(IntPtr hMenu, uint uFlags, int x, int y, int nReserved, IntPtr hWnd, IntPtr prcRect);
|
||||
[DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
#endif
|
||||
}
|
||||
2
Assets/02_Scripts/Desktop/TrayIcon.cs.meta
Normal file
2
Assets/02_Scripts/Desktop/TrayIcon.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a951781c3ff22c04193a69b7db01010f
|
||||
84
Assets/02_Scripts/Desktop/Win32.cs
Normal file
84
Assets/02_Scripts/Desktop/Win32.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
/// <summary>
|
||||
/// 데스크톱 오버레이에 필요한 Win32 선언 모음.
|
||||
/// 선언 자체는 무해하며, 실제 호출부에서 에디터 여부를 가드한다.
|
||||
/// </summary>
|
||||
internal static class Win32
|
||||
{
|
||||
public const int GWL_STYLE = -16;
|
||||
public const int GWL_EXSTYLE = -20;
|
||||
|
||||
public const uint WS_POPUP = 0x80000000;
|
||||
public const uint WS_VISIBLE = 0x10000000;
|
||||
|
||||
public const uint WS_EX_TOOLWINDOW = 0x00000080; // 작업표시줄/Alt+Tab 에서 숨김
|
||||
public const uint WS_EX_APPWINDOW = 0x00040000;
|
||||
public const uint WS_EX_TRANSPARENT = 0x00000020; // 마우스 히트테스트 통과
|
||||
public const uint WS_EX_NOACTIVATE = 0x08000000; // 클릭해도 포커스를 뺏지 않음
|
||||
public const uint WS_EX_LAYERED = 0x00080000; // 최상위 창의 클릭 통과에 필요
|
||||
|
||||
public const uint LWA_COLORKEY = 0x00000001;
|
||||
public const uint LWA_ALPHA = 0x00000002;
|
||||
|
||||
public static readonly IntPtr HWND_TOPMOST = new IntPtr(-1);
|
||||
public const uint SWP_NOSIZE = 0x0001;
|
||||
public const uint SWP_NOMOVE = 0x0002;
|
||||
public const uint SWP_NOACTIVATE = 0x0010;
|
||||
public const uint SWP_FRAMECHANGED = 0x0020;
|
||||
public const uint SWP_SHOWWINDOW = 0x0040;
|
||||
|
||||
public const int SM_XVIRTUALSCREEN = 76;
|
||||
public const int SM_YVIRTUALSCREEN = 77;
|
||||
public const int SM_CXVIRTUALSCREEN = 78;
|
||||
public const int SM_CYVIRTUALSCREEN = 79;
|
||||
|
||||
public const int SW_HIDE = 0;
|
||||
public const int SW_SHOW = 5;
|
||||
|
||||
public const int VK_SHIFT = 0x10;
|
||||
public const int VK_CONTROL = 0x11;
|
||||
public const int VK_MENU = 0x12; // Alt
|
||||
public const int VK_1 = 0x31;
|
||||
public const int VK_2 = 0x32;
|
||||
public const int VK_3 = 0x33;
|
||||
public const int VK_N = 0x4E;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct MARGINS
|
||||
{
|
||||
public int cxLeftWidth, cxRightWidth, cyTopHeight, cyBottomHeight;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct POINT
|
||||
{
|
||||
public int x, y;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct RECT
|
||||
{
|
||||
public int left, top, right, bottom;
|
||||
public int Width => right - left;
|
||||
public int Height => bottom - top;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetActiveWindow();
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern IntPtr FindWindowW(string lpClassName, string lpWindowName);
|
||||
[DllImport("user32.dll")] public static extern uint GetWindowLongW(IntPtr hWnd, int nIndex);
|
||||
[DllImport("user32.dll")] public static extern uint SetWindowLongW(IntPtr hWnd, int nIndex, uint dwNewLong);
|
||||
[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
|
||||
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
||||
[DllImport("user32.dll")] public static extern int GetSystemMetrics(int nIndex);
|
||||
[DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT lpPoint);
|
||||
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
|
||||
[DllImport("user32.dll")] public static extern short GetAsyncKeyState(int vKey);
|
||||
[DllImport("user32.dll")] public static extern bool SetLayeredWindowAttributes(IntPtr hWnd, uint crKey, byte bAlpha, uint dwFlags);
|
||||
[DllImport("dwmapi.dll")] public static extern int DwmExtendFrameIntoClientArea(IntPtr hWnd, ref MARGINS pMarInset);
|
||||
|
||||
public static bool IsKeyDown(int vKey) => (GetAsyncKeyState(vKey) & 0x8000) != 0;
|
||||
}
|
||||
#endif
|
||||
2
Assets/02_Scripts/Desktop/Win32.cs.meta
Normal file
2
Assets/02_Scripts/Desktop/Win32.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 014f94e37c2e5de4bb0fbe5e0831e18b
|
||||
Reference in New Issue
Block a user