using System.Collections; using UnityEngine; using UnityEngine.InputSystem; using UnityEngine.Rendering.Universal; #if !UNITY_EDITOR && UNITY_STANDALONE_WIN using System; #endif /// /// 창을 테두리 없는 최상단 투명 창으로 만든다. /// 작업표시줄과 Alt+Tab 에서도 숨긴다. /// 에디터에서는 아무것도 하지 않는다 (에디터 창이 사라지는 사고 방지). /// [RequireComponent(typeof(Camera))] public class TransparentWindow : MonoBehaviour { public enum CoverageMode { /// 플레이어 설정의 기본 해상도를 그대로 쓴다. 디버깅용. FixedSize, /// 주 모니터를 꽉 채운다. 해상도가 달라도 자동으로 맞춘다. PrimaryMonitor, /// 모든 모니터를 합친 가상 데스크톱 전체를 덮는다. VirtualDesktop, } [Header("창 동작")] [Tooltip("창이 덮을 범위. 해상도가 바뀌면 자동으로 다시 맞춘다")] [SerializeField] CoverageMode coverage = CoverageMode.PrimaryMonitor; [Tooltip("해상도/모니터 변경을 확인하는 주기(초). 0 이면 확인하지 않는다")] [SerializeField] float boundsCheckInterval = 2f; [Tooltip("작업표시줄 / Alt+Tab 에서 숨김 (WS_EX_TOOLWINDOW)")] [SerializeField] bool hideFromTaskbar = true; [Header("렌더링")] [Tooltip("URP 포스트프로세싱 비활성화")] [SerializeField] bool forceDisablePostProcessing = true; [Tooltip("포커스가 있을 때 Esc 로 종료. NOACTIVATE 를 켜면 동작하지 않으므로 GlobalExitHotkey 를 함께 쓸 것")] [SerializeField] bool quitOnEscape = true; Camera cam; /// 플레이어 창 핸들. 준비되기 전에는 Zero. public System.IntPtr Hwnd { get; private set; } = System.IntPtr.Zero; /// Win32 스타일 적용이 끝났는지. public bool IsReady { get; private set; } void Awake() { cam = GetComponent(); // 알파 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; } void Start() { #if !UNITY_EDITOR && UNITY_STANDALONE_WIN StartCoroutine(ApplyWindowStyle()); #else Debug.Log("[TransparentWindow] 에디터에서는 Win32 적용을 건너뜁니다. 빌드해서 테스트하세요."); #endif } // 마지막으로 Esc 종료가 막힌 프레임. 채팅창이 열려 있는 동안 매 프레임 갱신된다. int escapeSuppressedFrame = -10; /// /// 이번 프레임의 Esc 를 종료로 해석하지 않는다. /// /// 채팅창처럼 Esc 에 자기 의미가 있는 UI 는 열려 있는 동안 매 프레임 이걸 부른다. /// 한 프레임 유예를 두는 이유: 채팅창이 Esc 로 닫히면서 억제를 멈춘 바로 그 프레임에 /// 여기서 같은 Esc 를 다시 읽으면 앱이 꺼져버린다. 스크립트 실행 순서에 기대지 않도록 /// 프레임 번호로 판단한다. /// public void SuppressEscapeQuit() { escapeSuppressedFrame = Time.frameCount; } void Update() { // 포커스가 있을 때만 동작하는 보조 탈출구 if (quitOnEscape && Time.frameCount - escapeSuppressedFrame > 1) { var kb = Keyboard.current; if (kb != null && kb.escapeKey.wasPressedThisFrame) Application.Quit(); } #if !UNITY_EDITOR && UNITY_STANDALONE_WIN CheckBoundsChanged(); #endif } #if !UNITY_EDITOR && UNITY_STANDALONE_WIN /// 설정한 범위에 해당하는 화면 사각형을 구한다. void GetTargetBounds(out int x, out int y, out int w, out int h) { switch (coverage) { case CoverageMode.PrimaryMonitor: x = 0; y = 0; w = Win32.GetSystemMetrics(Win32.SM_CXSCREEN); h = Win32.GetSystemMetrics(Win32.SM_CYSCREEN); break; case CoverageMode.VirtualDesktop: x = Win32.GetSystemMetrics(Win32.SM_XVIRTUALSCREEN); y = Win32.GetSystemMetrics(Win32.SM_YVIRTUALSCREEN); w = Win32.GetSystemMetrics(Win32.SM_CXVIRTUALSCREEN); h = Win32.GetSystemMetrics(Win32.SM_CYVIRTUALSCREEN); break; default: x = 0; y = 0; w = Screen.width; h = Screen.height; break; } } bool applying; Vector4 appliedBounds = new Vector4(-1f, -1f, -1f, -1f); float nextBoundsCheck; /// /// 해상도 변경, 모니터 연결/해제, 배율 변경에 대응한다. /// 이걸 안 하면 모니터를 바꿔 꽂았을 때 창이 예전 크기로 남아 화면 일부만 덮는다. /// void CheckBoundsChanged() { if (!IsReady || applying) return; if (boundsCheckInterval <= 0f) return; if (Time.unscaledTime < nextBoundsCheck) return; nextBoundsCheck = Time.unscaledTime + boundsCheckInterval; GetTargetBounds(out int x, out int y, out int w, out int h); var now = new Vector4(x, y, w, h); if (now == appliedBounds) return; Debug.Log($"[TransparentWindow] 화면 구성 변경 감지: {appliedBounds} -> {now}. 다시 적용합니다."); StartCoroutine(ApplyWindowStyle()); } IEnumerator ApplyWindowStyle() { applying = true; GetTargetBounds(out int x, out int y, out int w, out int h); appliedBounds = new Vector4(x, y, w, h); if (coverage != CoverageMode.FixedSize && (w != Screen.width || h != Screen.height)) { 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; applying = false; Debug.Log($"[TransparentWindow] 적용 완료: {coverage} {x},{y} {w}x{h}"); } #endif }