Files
MyCharacterAgent/Assets/02_Scripts/Desktop/DesktopPlatformScanner.cs
2026-09-01 14:40:02 +09:00

310 lines
12 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;
// 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<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;
}
/// <summary>
/// 화면 좌표로 바닥 발판 하나를 직접 만든다. 창 목록에서 바닥을 얻지 못했을 때의 보루.
///
/// 바닥이 하나도 없으면 캐릭터가 끝없이 떨어지다 화면 가운데로 되돌아오기를
/// 반복한다(WindowClimber.TickFalling). 그 상태로는 아무것도 확인할 수 없다.
/// </summary>
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<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) &&
AddPlatform(map, work.left, work.right, work.bottom, "바닥", true, IntPtr.Zero, work.left))
{
return;
}
// 작업 영역을 못 구했거나 화면 밖으로 잘려나갔을 때. 바닥 없이 두면 안 된다.
AddScreenFloor(0f, "바닥(대체)");
}
/// <returns>실제로 발판이 추가됐는지. 너무 좁거나 화면 밖이면 false.</returns>
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
/// <summary>
/// 에디터에는 열거할 창이 없다. 그래서 발판이 하나도 없고, 캐릭터는 끝없이
/// 떨어졌다가 화면 가운데로 되돌아오기를 반복한다.
///
/// 빌드에서 작업표시줄이 해주던 역할을 대신할 바닥 하나만 깔아준다.
/// 창에 올라타는 동작 자체는 여전히 빌드에서만 확인할 수 있다.
/// </summary>
void Scan()
{
platforms.Clear();
if (includeFloor) AddScreenFloor(editorFloorY, "바닥(에디터)");
Rescanned?.Invoke();
}
#endif
}