using UnityEngine; #if !UNITY_EDITOR && UNITY_STANDALONE_WIN using System; using System.Runtime.InteropServices; #endif /// /// 시스템 트레이 아이콘과 우클릭 메뉴. /// /// WS_EX_TOOLWINDOW 로 작업표시줄에서 사라졌기 때문에 종료/제어 수단이 필요하다. /// /// 구현 노트: Shell_NotifyIcon 은 콜백 메시지를 받을 창이 필요한데, Unity 창의 /// 윈도우 프로시저를 SetWindowLongPtr 로 가로채는 것은 관리 델리게이트 수명과 /// 재진입 문제가 있어 위험하다. 대신 전용 창을 따로 만들어 거기에 아이콘을 걸고, /// PeekMessage 를 그 창으로 한정해 직접 펌핑한다. Unity 의 메시지 루프는 건드리지 않는다. /// /// HWND_MESSAGE(메시지 전용 창)를 쓰지 않는 이유: 메시지 전용 창은 브로드캐스트 /// 메시지를 받지 못해 TaskbarCreated 재등록이 동작하지 않고, z-order 가 없어 /// SetForegroundWindow 가 실패해 팝업 메뉴가 제대로 닫히지 않는다. /// 대신 WS_EX_TOOLWINDOW + WS_POPUP 인 일반 최상위 창을 만들되 표시하지 않는다. /// public class TrayIcon : MonoBehaviour { [SerializeField] string tooltip = "MyCharacterAgent"; [SerializeField] bool verboseLog = true; TransparentWindow window; void Awake() { window = GetComponent(); if (window == null) window = FindFirstObjectByType(); } #if !UNITY_EDITOR && UNITY_STANDALONE_WIN const string ClassName = "MyCharacterAgentTrayWnd"; const int ID_TOGGLE = 1; const int ID_EXIT = 2; const int ID_AUTOCLIMB = 3; 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; } /// /// 우리 창의 윈도우 프로시저. Unity 메인 스레드의 DispatchMessage 에서 호출된다. /// 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 ? "캐릭터 숨기기" : "캐릭터 보이기"); // 자동 올라타기는 켜고 끄는 항목이라 체크 표시로 현재 상태를 보여준다. var climber = FindFirstObjectByType(); if (climber != null) { uint flags = MF_STRING | (climber.AutoClimb ? MF_CHECKED : 0); AppendMenuW(menu, flags, ID_AUTOCLIMB, "자동으로 창에 올라가기"); } 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(); } else if (cmd == ID_AUTOCLIMB) { var target = FindFirstObjectByType(); if (target != null) { target.AutoClimb = !target.AutoClimb; Debug.Log($"[TrayIcon] 자동으로 창에 올라가기 = {target.AutoClimb}"); } } } 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 MF_CHECKED = 0x0008; // 항목 앞에 체크 표시 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 }