first commit

This commit is contained in:
2026-08-25 00:55:37 +09:00
commit 8d1881749c
72 changed files with 3250 additions and 0 deletions

View 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);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5ca4ab0faf560dd4f8fcf2d3093dceb7

View 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
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 83652a9ef9463bc4aabdd81cc62a3954

View 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
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e3227c6d05a07a640bb1d96118c0a82e

View 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
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a951781c3ff22c04193a69b7db01010f

View 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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 014f94e37c2e5de4bb0fbe5e0831e18b