using System.Collections.Generic; using UnityEngine; #if !UNITY_EDITOR && UNITY_STANDALONE_WIN using System; using System.Text; #endif /// 캐릭터가 올라설 수 있는 발판 한 구간. 모두 Unity 화면 좌표(좌하단 원점). public struct DesktopPlatform { public float Y; // 발판 높이 public float XMin; // 왼쪽 끝 (가려진 부분을 뺀 실제 구간) public float XMax; // 오른쪽 끝 public string Title; // 어떤 창인지 (바닥이면 "바닥") public bool IsFloor; /// 어느 창의 발판인지. 탑승과 "새 창 감지"에 쓴다. 바닥은 Zero. public System.IntPtr Hwnd; /// /// 창 전체의 왼쪽 끝(가림 계산 전). 탑승 중 창이 움직였을 때 /// 캐릭터의 상대 위치를 유지하는 기준이 된다. 구간(XMin)은 가림에 따라 /// 달라지므로 기준으로 쓸 수 없다. /// public float WindowLeft; public float Width => XMax - XMin; public float Center => (XMin + XMax) * 0.5f; } /// /// 열려 있는 창들의 위쪽 모서리를 찾아 캐릭터가 설 수 있는 발판 목록을 만든다. /// /// 참고한 오픈소스 구현들이 공통적으로 빠뜨리는 네 가지를 처리한다. /// 1. DWMWA_CLOAKED — UWP 앱은 닫아도 "숨겨진 채 살아있는" 창을 남긴다. /// 거르지 않으면 캐릭터가 아무것도 없는 허공에 선다. /// 2. DWMWA_EXTENDED_FRAME_BOUNDS — GetWindowRect 는 Win10/11 의 투명한 리사이즈 /// 여백까지 포함해 실제 보이는 창보다 좌우로 7px 정도 넓다. /// 3. Z-order 가림 — 앞 창에 가려진 구간에 서면 허공에 뜬 것처럼 보인다. /// 4. 폴링 주기 — 매 프레임 EnumWindows 를 도는 것은 시스템 콜 낭비다. /// public class DesktopPlatformScanner : MonoBehaviour { [Header("참조")] [Tooltip("비우면 씬에서 탐색")] [SerializeField] TransparentWindow window; // minPlatformWidth 는 빌드에서만, editorFloorY 는 에디터에서만 쓰인다. // 반대쪽 변형에서는 "할당했지만 안 씀" 경고가 뜬다. #pragma warning disable 0414 [Header("스캔")] [Tooltip("창 목록을 다시 훑는 주기(초). 매 프레임 돌 필요가 없다")] [SerializeField] float scanInterval = 0.25f; [Tooltip("이보다 좁은 구간은 발판으로 쓰지 않는다(픽셀)")] [SerializeField] float minPlatformWidth = 80f; [Tooltip("작업 영역 바닥(작업표시줄 위)을 항상 발판으로 포함한다")] [SerializeField] bool includeFloor = true; [Tooltip("에디터에서 쓸 바닥 높이(화면 좌표, 0 이 맨 아래). " + "빌드에서는 작업표시줄 위를 직접 찾으므로 이 값을 쓰지 않는다")] [SerializeField] float editorFloorY = 0f; #pragma warning restore 0414 readonly List platforms = new List(); float nextScanTime; /// 가장 최근에 찾은 발판들. 화면 좌표 기준. public IReadOnlyList Platforms => platforms; /// 스캔이 끝날 때마다 호출된다. public event System.Action Rescanned; void Awake() { if (window == null) window = FindFirstObjectByType(); } void Update() { if (Time.unscaledTime < nextScanTime) return; nextScanTime = Time.unscaledTime + Mathf.Max(0.05f, scanInterval); Scan(); } /// /// 지정한 X 위치에서, 주어진 높이보다 아래에 있는 발판 중 가장 높은 것을 찾는다. /// 낙하 중 착지할 지점을 고를 때 쓴다. /// public bool TryFindPlatformBelow(float x, float y, float tolerance, out DesktopPlatform found) { found = default; bool any = false; foreach (var p in platforms) { if (x < p.XMin || x > p.XMax) continue; if (p.Y > y + tolerance) continue; // 발보다 위에 있는 것은 제외 if (any && p.Y <= found.Y) continue; // 더 높은 것을 우선 found = p; any = true; } return any; } /// /// 화면 좌표로 바닥 발판 하나를 직접 만든다. 창 목록에서 바닥을 얻지 못했을 때의 보루. /// /// 바닥이 하나도 없으면 캐릭터가 끝없이 떨어지다 화면 가운데로 되돌아오기를 /// 반복한다(WindowClimber.TickFalling). 그 상태로는 아무것도 확인할 수 없다. /// void AddScreenFloor(float y, string title) { platforms.Add(new DesktopPlatform { Y = y, XMin = 0f, XMax = Screen.width, Title = title, IsFloor = true, Hwnd = System.IntPtr.Zero, WindowLeft = 0f, }); } #if !UNITY_EDITOR && UNITY_STANDALONE_WIN // EnumWindows 콜백은 네이티브가 호출하므로 GC 되지 않게 붙잡아 둔다. Win32.EnumWindowsProc enumProc; readonly List collected = new List(); readonly List collectedTitles = new List(); readonly List collectedHwnds = new List(); readonly StringBuilder titleBuffer = new StringBuilder(256); void Scan() { if (window == null || !window.IsReady) return; if (!DesktopCursor.TryGetMapping(window.Hwnd, out var map)) return; collected.Clear(); collectedTitles.Clear(); collectedHwnds.Clear(); enumProc ??= OnEnumWindow; Win32.EnumWindows(enumProc, IntPtr.Zero); platforms.Clear(); BuildPlatforms(map); if (includeFloor) AddFloor(map); Rescanned?.Invoke(); } /// EnumWindows 는 Z-order 앞쪽(위에 있는 창)부터 순회한다. bool OnEnumWindow(IntPtr hwnd, IntPtr lParam) { if (hwnd == window.Hwnd) return true; // 우리 창은 제외. 최상단이라 전부 가려버린다 if (!Win32.IsWindowVisible(hwnd)) return true; if (Win32.IsIconic(hwnd)) return true; // 최소화 if (Win32.GetWindowTextLengthW(hwnd) == 0) return true; // 제목 없는 보조 창 uint ex = Win32.GetWindowLongW(hwnd, Win32.GWL_EXSTYLE); if ((ex & Win32.WS_EX_TOOLWINDOW) != 0) return true; // 도구 창 // UWP 는 닫아도 숨겨진 채 살아있는 창을 남긴다. 이걸 거르지 않으면 허공에 선다. if (Win32.DwmGetWindowAttribute(hwnd, Win32.DWMWA_CLOAKED, out int cloaked, sizeof(int)) == 0 && cloaked != 0) { return true; } if (!TryGetFrameBounds(hwnd, out Win32.RECT rect)) return true; if (rect.Width <= 0 || rect.Height <= 0) return true; titleBuffer.Length = 0; Win32.GetWindowTextW(hwnd, titleBuffer, titleBuffer.Capacity); collected.Add(rect); collectedTitles.Add(titleBuffer.ToString()); collectedHwnds.Add(hwnd); return true; } /// /// GetWindowRect 는 Win10/11 의 투명한 리사이즈 여백까지 포함해 실제보다 넓다. /// DWM 이 알려주는 실제 프레임을 우선 쓰고, 실패하면 기존 방식으로 되돌린다. /// static bool TryGetFrameBounds(IntPtr hwnd, out Win32.RECT rect) { int size = System.Runtime.InteropServices.Marshal.SizeOf(typeof(Win32.RECT)); if (Win32.DwmGetWindowAttribute(hwnd, Win32.DWMWA_EXTENDED_FRAME_BOUNDS, out rect, size) == 0) { return true; } return Win32.GetWindowRect(hwnd, out rect); } /// /// 각 창의 위쪽 모서리에서, 앞에 있는 창들에 가려지지 않은 구간만 발판으로 남긴다. /// collected 는 Z-order 앞쪽부터이므로, 자기보다 앞선 항목들이 곧 가리는 창이다. /// void BuildPlatforms(DesktopCursor.Mapping map) { var segments = new List<(float a, float b)>(); for (int i = 0; i < collected.Count; i++) { Win32.RECT r = collected[i]; segments.Clear(); segments.Add((r.left, r.right)); for (int j = 0; j < i; j++) // j 는 i 보다 앞(위)에 있는 창 { Win32.RECT o = collected[j]; // 가리는 창이 이 높이를 세로로 지나가야 실제로 가린다. if (o.top > r.top || r.top > o.bottom) continue; Subtract(segments, o.left, o.right); if (segments.Count == 0) break; } foreach (var seg in segments) { AddPlatform(map, seg.a, seg.b, r.top, collectedTitles[i], false, collectedHwnds[i], r.left); } } } /// 구간 목록에서 [cutA, cutB] 를 잘라낸다. static void Subtract(List<(float a, float b)> segments, float cutA, float cutB) { for (int i = segments.Count - 1; i >= 0; i--) { var (a, b) = segments[i]; if (cutB <= a || cutA >= b) continue; // 겹치지 않음 segments.RemoveAt(i); if (cutA > a) segments.Insert(i, (a, cutA)); // 왼쪽 잔여 if (cutB < b) segments.Insert(i, (cutB, b)); // 오른쪽 잔여 } } void AddFloor(DesktopCursor.Mapping map) { // 작업 영역은 작업표시줄을 제외한 범위다. 그 바닥에 서면 작업표시줄 위에 선다. if (Win32.SystemParametersInfoW(Win32.SPI_GETWORKAREA, 0, out Win32.RECT work, 0) && AddPlatform(map, work.left, work.right, work.bottom, "바닥", true, IntPtr.Zero, work.left)) { return; } // 작업 영역을 못 구했거나 화면 밖으로 잘려나갔을 때. 바닥 없이 두면 안 된다. AddScreenFloor(0f, "바닥(대체)"); } /// 실제로 발판이 추가됐는지. 너무 좁거나 화면 밖이면 false. bool AddPlatform(DesktopCursor.Mapping map, float desktopLeft, float desktopRight, float desktopTop, string title, bool isFloor, IntPtr hwnd, float desktopWindowLeft) { float xMin = map.ToScreenX(desktopLeft); float xMax = map.ToScreenX(desktopRight); float y = map.ToScreenY(desktopTop); // 화면 밖으로 벗어난 부분은 잘라낸다. xMin = Mathf.Max(xMin, 0f); xMax = Mathf.Min(xMax, Screen.width); if (xMax - xMin < minPlatformWidth) return false; if (y < 0f || y > Screen.height) return false; platforms.Add(new DesktopPlatform { Y = y, XMin = xMin, XMax = xMax, Title = title, IsFloor = isFloor, Hwnd = hwnd, WindowLeft = map.ToScreenX(desktopWindowLeft), }); return true; } #else /// /// 에디터에는 열거할 창이 없다. 그래서 발판이 하나도 없고, 캐릭터는 끝없이 /// 떨어졌다가 화면 가운데로 되돌아오기를 반복한다. /// /// 빌드에서 작업표시줄이 해주던 역할을 대신할 바닥 하나만 깔아준다. /// 창에 올라타는 동작 자체는 여전히 빌드에서만 확인할 수 있다. /// void Scan() { platforms.Clear(); if (includeFloor) AddScreenFloor(editorFloorY, "바닥(에디터)"); Rescanned?.Invoke(); } #endif }