264 lines
9.7 KiB
C#
264 lines
9.7 KiB
C#
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
|
|
using System;
|
|
using System.Text;
|
|
#endif
|
|
|
|
/// <summary>캐릭터가 올라설 수 있는 발판 한 구간. 모두 Unity 화면 좌표(좌하단 원점).</summary>
|
|
public struct DesktopPlatform
|
|
{
|
|
public float Y; // 발판 높이
|
|
public float XMin; // 왼쪽 끝 (가려진 부분을 뺀 실제 구간)
|
|
public float XMax; // 오른쪽 끝
|
|
public string Title; // 어떤 창인지 (바닥이면 "바닥")
|
|
public bool IsFloor;
|
|
|
|
/// <summary>어느 창의 발판인지. 탑승과 "새 창 감지"에 쓴다. 바닥은 Zero.</summary>
|
|
public System.IntPtr Hwnd;
|
|
|
|
/// <summary>
|
|
/// 창 전체의 왼쪽 끝(가림 계산 전). 탑승 중 창이 움직였을 때
|
|
/// 캐릭터의 상대 위치를 유지하는 기준이 된다. 구간(XMin)은 가림에 따라
|
|
/// 달라지므로 기준으로 쓸 수 없다.
|
|
/// </summary>
|
|
public float WindowLeft;
|
|
|
|
public float Width => XMax - XMin;
|
|
public float Center => (XMin + XMax) * 0.5f;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 열려 있는 창들의 위쪽 모서리를 찾아 캐릭터가 설 수 있는 발판 목록을 만든다.
|
|
///
|
|
/// 참고한 오픈소스 구현들이 공통적으로 빠뜨리는 네 가지를 처리한다.
|
|
/// 1. DWMWA_CLOAKED — UWP 앱은 닫아도 "숨겨진 채 살아있는" 창을 남긴다.
|
|
/// 거르지 않으면 캐릭터가 아무것도 없는 허공에 선다.
|
|
/// 2. DWMWA_EXTENDED_FRAME_BOUNDS — GetWindowRect 는 Win10/11 의 투명한 리사이즈
|
|
/// 여백까지 포함해 실제 보이는 창보다 좌우로 7px 정도 넓다.
|
|
/// 3. Z-order 가림 — 앞 창에 가려진 구간에 서면 허공에 뜬 것처럼 보인다.
|
|
/// 4. 폴링 주기 — 매 프레임 EnumWindows 를 도는 것은 시스템 콜 낭비다.
|
|
/// </summary>
|
|
public class DesktopPlatformScanner : MonoBehaviour
|
|
{
|
|
[Header("참조")]
|
|
[Tooltip("비우면 씬에서 탐색")]
|
|
[SerializeField] TransparentWindow window;
|
|
|
|
[Header("스캔")]
|
|
[Tooltip("창 목록을 다시 훑는 주기(초). 매 프레임 돌 필요가 없다")]
|
|
[SerializeField] float scanInterval = 0.25f;
|
|
|
|
[Tooltip("이보다 좁은 구간은 발판으로 쓰지 않는다(픽셀)")]
|
|
[SerializeField] float minPlatformWidth = 80f;
|
|
|
|
[Tooltip("작업 영역 바닥(작업표시줄 위)을 항상 발판으로 포함한다")]
|
|
[SerializeField] bool includeFloor = true;
|
|
|
|
readonly List<DesktopPlatform> platforms = new List<DesktopPlatform>();
|
|
float nextScanTime;
|
|
|
|
/// <summary>가장 최근에 찾은 발판들. 화면 좌표 기준.</summary>
|
|
public IReadOnlyList<DesktopPlatform> Platforms => platforms;
|
|
|
|
/// <summary>스캔이 끝날 때마다 호출된다.</summary>
|
|
public event System.Action Rescanned;
|
|
|
|
void Awake()
|
|
{
|
|
if (window == null) window = FindFirstObjectByType<TransparentWindow>();
|
|
}
|
|
|
|
void Update()
|
|
{
|
|
if (Time.unscaledTime < nextScanTime) return;
|
|
nextScanTime = Time.unscaledTime + Mathf.Max(0.05f, scanInterval);
|
|
Scan();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 지정한 X 위치에서, 주어진 높이보다 아래에 있는 발판 중 가장 높은 것을 찾는다.
|
|
/// 낙하 중 착지할 지점을 고를 때 쓴다.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
|
|
|
|
// EnumWindows 콜백은 네이티브가 호출하므로 GC 되지 않게 붙잡아 둔다.
|
|
Win32.EnumWindowsProc enumProc;
|
|
readonly List<Win32.RECT> collected = new List<Win32.RECT>();
|
|
readonly List<string> collectedTitles = new List<string>();
|
|
readonly List<IntPtr> collectedHwnds = new List<IntPtr>();
|
|
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();
|
|
}
|
|
|
|
/// <summary>EnumWindows 는 Z-order 앞쪽(위에 있는 창)부터 순회한다.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// GetWindowRect 는 Win10/11 의 투명한 리사이즈 여백까지 포함해 실제보다 넓다.
|
|
/// DWM 이 알려주는 실제 프레임을 우선 쓰고, 실패하면 기존 방식으로 되돌린다.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 각 창의 위쪽 모서리에서, 앞에 있는 창들에 가려지지 않은 구간만 발판으로 남긴다.
|
|
/// collected 는 Z-order 앞쪽부터이므로, 자기보다 앞선 항목들이 곧 가리는 창이다.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>구간 목록에서 [cutA, cutB] 를 잘라낸다.</summary>
|
|
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)) return;
|
|
|
|
AddPlatform(map, work.left, work.right, work.bottom, "바닥", true, IntPtr.Zero, work.left);
|
|
}
|
|
|
|
void 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;
|
|
if (y < 0f || y > Screen.height) return;
|
|
|
|
platforms.Add(new DesktopPlatform
|
|
{
|
|
Y = y,
|
|
XMin = xMin,
|
|
XMax = xMax,
|
|
Title = title,
|
|
IsFloor = isFloor,
|
|
Hwnd = hwnd,
|
|
WindowLeft = map.ToScreenX(desktopWindowLeft),
|
|
});
|
|
}
|
|
|
|
#else
|
|
void Scan()
|
|
{
|
|
// 에디터에서는 창 열거를 하지 않는다. 빌드해서 확인할 것.
|
|
}
|
|
#endif
|
|
}
|