diff --git a/Assets/01_Scenes/MainScene.unity b/Assets/01_Scenes/MainScene.unity
index 0c4579f..9b5bf0f 100644
--- a/Assets/01_Scenes/MainScene.unity
+++ b/Assets/01_Scenes/MainScene.unity
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:4fc817dc4f068c238bd6596617328a5eeb0606b743401b728d29399b42987bbd
-size 15011
+oid sha256:85bc27b8e51f498881808e664903fbeaefb6c280a83c50129d70e327e9960edd
+size 17952
diff --git a/Assets/02_Scripts/Character/CharacterCameraFit.cs b/Assets/02_Scripts/Character/CharacterCameraFit.cs
new file mode 100644
index 0000000..9403dc8
--- /dev/null
+++ b/Assets/02_Scripts/Character/CharacterCameraFit.cs
@@ -0,0 +1,111 @@
+using UnityEngine;
+
+///
+/// 카메라를 직교 투영으로 바꾸고, 불러온 캐릭터 크기에 맞춰 자동으로 구도를 잡는다.
+///
+/// 왜 직교 투영인가:
+/// 원근 투영에서는 화면 중앙을 벗어날수록 캐릭터를 비스듬히 옆에서 보게 되어
+/// 형태가 왜곡된다. 데스크톱 캐릭터는 화면 어디로든 이동하므로 이 왜곡이 그대로
+/// 드러난다. 직교 투영은 위치와 무관하게 항상 같은 각도로 보인다.
+///
+/// 왜 자동인가:
+/// 사용자가 임의의 VRM 을 넣는 구조라 캐릭터 키를 미리 알 수 없다. 모델마다
+/// 직교 크기를 손으로 맞추면 모델을 바꿀 때마다 다시 맞춰야 한다.
+///
+public class CharacterCameraFit : MonoBehaviour
+{
+ [Header("참조")]
+ [Tooltip("비우면 같은 오브젝트의 Camera 또는 Camera.main")]
+ [SerializeField] Camera targetCamera;
+
+ [Tooltip("비우면 씬에서 탐색")]
+ [SerializeField] VrmCharacterLoader loader;
+
+ [Header("구도")]
+ [Tooltip("직교 투영으로 강제한다. 끄면 현재 투영 방식을 유지한 채 크기만 맞춘다")]
+ [SerializeField] bool forceOrthographic = true;
+
+ [Tooltip("캐릭터가 화면 높이에서 차지할 비율. 0.30면 화면의 30%")]
+ [Range(0.1f, 1f)]
+ [SerializeField] float screenHeightFraction = 0.30f;
+
+ [Tooltip("캐릭터가 처음에 화면 중앙에 오도록 카메라 위치도 맞춘다")]
+ [SerializeField] bool centerOnCharacter = true;
+
+ void Awake()
+ {
+ if (targetCamera == null) targetCamera = GetComponent();
+ if (targetCamera == null) targetCamera = Camera.main;
+ if (loader == null) loader = FindFirstObjectByType();
+
+ if (loader != null) loader.Loaded += OnCharacterLoaded;
+ }
+
+ void OnDestroy()
+ {
+ if (loader != null) loader.Loaded -= OnCharacterLoaded;
+ }
+
+ void OnCharacterLoaded(ICharacterAvatar avatar)
+ {
+ if (avatar != null) Fit(avatar.Root);
+ }
+
+ /// 캐릭터를 감싸도록 카메라 크기와 위치를 맞춘다.
+ public void Fit(Transform character)
+ {
+ if (targetCamera == null || character == null) return;
+
+ var renderers = character.GetComponentsInChildren(true);
+ if (renderers.Length == 0)
+ {
+ Debug.LogWarning("[CharacterCameraFit] 렌더러가 없어 구도를 잡지 못했습니다.");
+ return;
+ }
+
+ Bounds bounds = renderers[0].bounds;
+ for (int i = 1; i < renderers.Length; i++) bounds.Encapsulate(renderers[i].bounds);
+
+ float characterHeight = Mathf.Max(0.01f, bounds.size.y);
+
+ if (forceOrthographic) targetCamera.orthographic = true;
+
+ if (targetCamera.orthographic)
+ {
+ // orthographicSize 는 화면에 보이는 세계 높이의 절반이다.
+ // 보이는 높이 = 캐릭터 키 / 비율 이므로, 그 절반이 필요한 값이다.
+ targetCamera.orthographicSize = characterHeight / (2f * screenHeightFraction);
+ }
+ else
+ {
+ // 원근이면 거리로 맞춘다. 왜곡은 남으므로 권장하지 않는다.
+ float visibleHeight = characterHeight / screenHeightFraction;
+ float halfFovRad = targetCamera.fieldOfView * 0.5f * Mathf.Deg2Rad;
+ float distance = visibleHeight * 0.5f / Mathf.Tan(halfFovRad);
+
+ Vector3 dir = targetCamera.transform.forward;
+ targetCamera.transform.position = bounds.center - dir * distance;
+ }
+
+ if (centerOnCharacter)
+ {
+ // 카메라의 시선 방향(깊이)은 유지하고, 화면상 중앙만 캐릭터에 맞춘다.
+ Vector3 camPos = targetCamera.transform.position;
+ Vector3 toCharacter = bounds.center - camPos;
+ Vector3 forward = targetCamera.transform.forward;
+
+ // 깊이 성분만 남기고 나머지를 상쇄해 캐릭터가 화면 중앙에 오게 한다.
+ Vector3 depthOnly = Vector3.Project(toCharacter, forward);
+ targetCamera.transform.position = bounds.center - depthOnly;
+ }
+
+ // 직교 투영에서 near 가 캐릭터보다 앞에 있으면 잘려 보인다.
+ if (targetCamera.orthographic && targetCamera.nearClipPlane > 0.05f)
+ {
+ targetCamera.nearClipPlane = 0.05f;
+ }
+
+ Debug.Log($"[CharacterCameraFit] 구도 적용. 캐릭터 키 {characterHeight:F2}m, " +
+ $"직교={targetCamera.orthographic}, size={targetCamera.orthographicSize:F2}");
+ }
+}
diff --git a/Assets/02_Scripts/Character/CharacterCameraFit.cs.meta b/Assets/02_Scripts/Character/CharacterCameraFit.cs.meta
new file mode 100644
index 0000000..26f42b9
--- /dev/null
+++ b/Assets/02_Scripts/Character/CharacterCameraFit.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: de48ad5f60a02124cbb13459f90c9a3f
\ No newline at end of file
diff --git a/Assets/02_Scripts/Character/CharacterDragger.cs b/Assets/02_Scripts/Character/CharacterDragger.cs
new file mode 100644
index 0000000..8376810
--- /dev/null
+++ b/Assets/02_Scripts/Character/CharacterDragger.cs
@@ -0,0 +1,168 @@
+using UnityEngine;
+
+///
+/// 캐릭터를 마우스로 집어 옮긴다.
+///
+/// 구현 노트 1 — 왜 Unity Input 을 쓰지 않는가:
+/// 우리 창은 WS_EX_NOACTIVATE 라 포커스를 받지 않는다. 클릭 자체는 들어오지만
+/// 포커스 없는 상태의 입력은 신뢰도가 떨어지고, 커서가 창 밖으로 나가면 Unity 는
+/// 위치를 아예 모른다. GetAsyncKeyState 와 GetCursorPos 로 OS 에 직접 물으면
+/// 포커스와 무관하게 항상 정확하다.
+///
+/// 구현 노트 2 — 드래그 중 클릭 통과 잠금:
+/// 커서를 빠르게 움직이면 캐릭터 콜라이더를 벗어나는데, 그 순간 히트테스트가
+/// 클릭 통과를 다시 켜버리면 입력이 뒤 창으로 새면서 드래그가 끊긴다.
+/// 드래그 동안 ForceInteractive 로 히트테스트를 무력화한다.
+///
+/// 여기서 만든 "화면 좌표 -> 월드 위치" 배선은 창 올라타기에서도 그대로 쓴다.
+///
+[RequireComponent(typeof(ClickThroughHitTest))]
+public class CharacterDragger : MonoBehaviour
+{
+ [Header("참조")]
+ [Tooltip("비우면 같은 오브젝트의 Camera 또는 Camera.main")]
+ [SerializeField] Camera viewCamera;
+
+ [Tooltip("비우면 씬에서 탐색")]
+ [SerializeField] TransparentWindow window;
+
+ [Tooltip("비우면 씬에서 탐색")]
+ [SerializeField] VrmCharacterLoader loader;
+
+ [Header("동작")]
+ [Tooltip("0 이면 커서에 즉시 붙는다. 값을 올리면 살짝 끌려오는 느낌이 난다")]
+ [Range(0f, 30f)]
+ [SerializeField] float followSpeed = 0f;
+
+ [Tooltip("이 거리 이상 움직여야 드래그로 인정한다. 클릭과 드래그를 구분한다")]
+ [SerializeField] float dragThresholdPixels = 4f;
+
+ [Tooltip("화면 가장자리에서 추가로 띄울 여백(픽셀). 캐릭터가 잘리지 않도록 제한한다")]
+ [SerializeField] float screenMargin = 0f;
+
+ ClickThroughHitTest hitTest;
+
+ Transform target;
+ bool pressing; // 버튼이 눌린 상태(아직 드래그는 아닐 수 있음)
+ bool dragging; // 임계값을 넘겨 실제 드래그 중
+ Vector2 pressStartPos;
+ Vector2 grabScreenOffset; // 캐릭터 원점 - 커서 (화면 좌표)
+ Vector2 boundsOffMin, boundsOffMax; // 원점 기준 캐릭터의 화면상 범위
+ float dragDepth;
+
+ /// 드래그 중인지. 창 올라타기 등 자동 이동 로직이 이 동안 양보한다.
+ public bool IsDragging => dragging;
+
+ /// 드래그가 끝난 순간 호출. 창 올라타기가 착지 지점을 다시 계산할 때 쓴다.
+ public event System.Action DragEnded;
+
+ void Awake()
+ {
+ hitTest = GetComponent();
+ if (viewCamera == null) viewCamera = GetComponent();
+ if (viewCamera == null) viewCamera = Camera.main;
+ if (window == null) window = FindFirstObjectByType();
+ if (loader == null) loader = FindFirstObjectByType();
+ }
+
+ void Update()
+ {
+#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
+ bool buttonDown = Win32.IsKeyDown(Win32.VK_LBUTTON);
+#else
+ var mouse = UnityEngine.InputSystem.Mouse.current;
+ bool buttonDown = mouse != null && mouse.leftButton.isPressed;
+#endif
+
+ if (!pressing && buttonDown && hitTest.IsOverCharacter)
+ {
+ BeginPress();
+ }
+ else if (pressing && !buttonDown)
+ {
+ EndPress();
+ }
+
+ if (pressing) UpdatePress();
+ }
+
+ void BeginPress()
+ {
+ target = loader != null && loader.Current != null ? loader.Current.Root : null;
+ if (target == null) return;
+ if (!TryGetCursor(out Vector2 sp)) return;
+
+ pressing = true;
+ dragging = false;
+ pressStartPos = sp;
+
+ // 캐릭터가 놓인 깊이를 유지한다. 이 평면 위에서만 움직이므로 크기가 변하지 않는다.
+ dragDepth = Vector3.Dot(target.position - viewCamera.transform.position,
+ viewCamera.transform.forward);
+
+ // 깊이가 고정이라 화면상 크기도 고정이다. 시작할 때 한 번만 재면 된다.
+ if (!CharacterScreenBounds.TryMeasure(viewCamera, target, out boundsOffMin, out boundsOffMax))
+ {
+ boundsOffMin = boundsOffMax = Vector2.zero;
+ }
+
+ Vector2 originScreen = viewCamera.WorldToScreenPoint(target.position);
+ grabScreenOffset = originScreen - sp;
+ }
+
+ void UpdatePress()
+ {
+ if (target == null) { EndPress(); return; }
+ if (!TryGetCursor(out Vector2 sp)) return;
+
+ if (!dragging)
+ {
+ // 임계값을 넘기 전까지는 클릭으로 본다. 제자리 클릭에 캐릭터가 튀지 않게.
+ if (Vector2.Distance(sp, pressStartPos) < dragThresholdPixels) return;
+
+ dragging = true;
+ hitTest.ForceInteractive = true;
+ }
+
+ // 화면 좌표에서 제한한다. 월드에서 제한하면 카메라 각도에 따라 계산이 복잡해진다.
+ Vector2 desiredScreen = sp + grabScreenOffset;
+ desiredScreen = CharacterScreenBounds.Clamp(desiredScreen, boundsOffMin, boundsOffMax, screenMargin);
+
+ Vector3 desired = ScreenToWorld(desiredScreen);
+
+ if (followSpeed <= 0.01f)
+ {
+ target.position = desired;
+ }
+ else
+ {
+ float t = 1f - Mathf.Exp(-followSpeed * Time.deltaTime); // 프레임레이트 독립
+ target.position = Vector3.Lerp(target.position, desired, t);
+ }
+ }
+
+ void EndPress()
+ {
+ bool wasDragging = dragging;
+ Transform dragged = target;
+
+ pressing = false;
+ dragging = false;
+ hitTest.ForceInteractive = false;
+ target = null;
+
+ if (wasDragging && dragged != null) DragEnded?.Invoke(dragged);
+ }
+
+ bool TryGetCursor(out Vector2 screenPos)
+ {
+ System.IntPtr hwnd = window != null ? window.Hwnd : System.IntPtr.Zero;
+ return DesktopCursor.TryGetScreenPosition(hwnd, out screenPos);
+ }
+
+ /// 화면 좌표를 드래그 평면(고정 깊이) 위의 월드 좌표로 변환한다.
+ Vector3 ScreenToWorld(Vector2 screenPos)
+ {
+ return viewCamera.ScreenToWorldPoint(new Vector3(screenPos.x, screenPos.y, dragDepth));
+ }
+}
diff --git a/Assets/02_Scripts/Character/CharacterDragger.cs.meta b/Assets/02_Scripts/Character/CharacterDragger.cs.meta
new file mode 100644
index 0000000..f92d937
--- /dev/null
+++ b/Assets/02_Scripts/Character/CharacterDragger.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c2ae16f85f0c0304eb3c60b5172e5306
\ No newline at end of file
diff --git a/Assets/02_Scripts/Character/CharacterScreenBounds.cs b/Assets/02_Scripts/Character/CharacterScreenBounds.cs
new file mode 100644
index 0000000..984774a
--- /dev/null
+++ b/Assets/02_Scripts/Character/CharacterScreenBounds.cs
@@ -0,0 +1,75 @@
+using UnityEngine;
+
+///
+/// 캐릭터가 화면 밖으로 나가 잘리지 않도록 화면 좌표를 제한한다.
+///
+/// 드래그와 창 올라타기 양쪽에서 쓴다. 캐릭터를 화면 가장자리 창의 타이틀바에
+/// 올려놓을 때도 같은 제한이 필요하기 때문에 별도 유틸로 분리했다.
+///
+/// 캐릭터는 고정된 깊이 평면 위에서만 움직이므로, 화면상 크기는 드래그 중에
+/// 변하지 않는다. 그래서 시작할 때 한 번만 재면 된다.
+///
+public static class CharacterScreenBounds
+{
+ ///
+ /// 캐릭터 원점(transform.position) 기준으로, 화면상 어디까지 뻗어 있는지 잰다.
+ /// 반환되는 offMin/offMax 는 원점의 화면 좌표에 대한 상대 오프셋이다.
+ ///
+ public static bool TryMeasure(Camera cam, Transform character, out Vector2 offMin, out Vector2 offMax)
+ {
+ offMin = offMax = Vector2.zero;
+ if (cam == null || character == null) return false;
+
+ var renderers = character.GetComponentsInChildren(true);
+ if (renderers.Length == 0) return false;
+
+ Bounds world = renderers[0].bounds;
+ for (int i = 1; i < renderers.Length; i++) world.Encapsulate(renderers[i].bounds);
+
+ Vector3 c = world.center;
+ Vector3 e = world.extents;
+
+ // AABB 를 화면에 투영하면 축 정렬이 깨지므로 8개 꼭짓점을 모두 변환해 감싼다.
+ var min = new Vector2(float.MaxValue, float.MaxValue);
+ var max = new Vector2(float.MinValue, float.MinValue);
+
+ for (int i = 0; i < 8; i++)
+ {
+ var corner = new Vector3(
+ c.x + ((i & 1) == 0 ? -e.x : e.x),
+ c.y + ((i & 2) == 0 ? -e.y : e.y),
+ c.z + ((i & 4) == 0 ? -e.z : e.z));
+
+ Vector3 sp = cam.WorldToScreenPoint(corner);
+ min = Vector2.Min(min, sp);
+ max = Vector2.Max(max, sp);
+ }
+
+ Vector2 origin = cam.WorldToScreenPoint(character.position);
+ offMin = min - origin;
+ offMax = max - origin;
+ return true;
+ }
+
+ ///
+ /// 캐릭터 원점의 화면 좌표를, 캐릭터 전체가 화면 안에 들어오도록 제한한다.
+ ///
+ /// 가장자리에서 추가로 띄울 여백(픽셀)
+ public static Vector2 Clamp(Vector2 origin, Vector2 offMin, Vector2 offMax, float margin = 0f)
+ {
+ return new Vector2(
+ ClampAxis(origin.x, offMin.x, offMax.x, Screen.width, margin),
+ ClampAxis(origin.y, offMin.y, offMax.y, Screen.height, margin));
+ }
+
+ static float ClampAxis(float value, float offMin, float offMax, float screenSize, float margin)
+ {
+ float lo = margin - offMin;
+ float hi = screenSize - margin - offMax;
+
+ // 캐릭터가 화면보다 큰 경우 범위가 뒤집힌다. 그때는 가운데로 보낸다.
+ if (lo > hi) return (lo + hi) * 0.5f;
+
+ return Mathf.Clamp(value, lo, hi);
+ }
+}
diff --git a/Assets/02_Scripts/Character/CharacterScreenBounds.cs.meta b/Assets/02_Scripts/Character/CharacterScreenBounds.cs.meta
new file mode 100644
index 0000000..bb0ed3c
--- /dev/null
+++ b/Assets/02_Scripts/Character/CharacterScreenBounds.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 75b72713dd560d3459533109b137171a
\ No newline at end of file
diff --git a/Assets/02_Scripts/Character/HeadLookAt.cs b/Assets/02_Scripts/Character/HeadLookAt.cs
index 01df453..f217cfa 100644
--- a/Assets/02_Scripts/Character/HeadLookAt.cs
+++ b/Assets/02_Scripts/Character/HeadLookAt.cs
@@ -52,6 +52,14 @@ public class HeadLookAt : MonoBehaviour
[Tooltip("대상을 놓쳤을 때 정면으로 돌아가기까지의 유예 시간(초)")]
[SerializeField] float returnDelay = 1.5f;
+ [Header("주의 범위")]
+ [Tooltip("이 거리 안에서는 완전히 쳐다본다. 캐릭터의 화면상 키를 1 로 하는 배수. " +
+ "픽셀이 아니라 배수라서 캐릭터 크기나 해상도가 달라져도 체감이 유지된다")]
+ [SerializeField] float attentionRadius = 0.8f;
+
+ [Tooltip("반경 바깥으로 이만큼 더 멀어지는 동안 서서히 정면으로 돌아간다")]
+ [SerializeField] float attentionFalloff = 0.7f;
+
Transform head;
Transform neck;
Transform root;
@@ -59,6 +67,9 @@ public class HeadLookAt : MonoBehaviour
// 기준(정지) 자세. 회전 누적을 막기 위해 매 프레임 여기로 되돌린 뒤 적용한다.
Quaternion restHead, restNeck;
+ // 주의 반경의 기준 단위. 캐릭터가 화면에서 차지하는 높이(픽셀).
+ float characterScreenHeight = 200f;
+
Vector2 currentAngles; // x = yaw, y = pitch
float lastSeenTime = -999f;
@@ -93,6 +104,14 @@ bool TryResolveBones()
{
restHead = head.localRotation;
if (neck != null) restNeck = neck.localRotation;
+
+ // 캐릭터는 고정 깊이 평면 위에 있으므로 화면상 크기가 변하지 않는다. 한 번만 잰다.
+ if (viewCamera != null &&
+ CharacterScreenBounds.TryMeasure(viewCamera, root, out Vector2 offMin, out Vector2 offMax))
+ {
+ characterScreenHeight = Mathf.Max(1f, offMax.y - offMin.y);
+ }
+
resolved = true;
return true;
}
@@ -112,10 +131,10 @@ void LateUpdate()
if (head == null) return;
Vector2 desired;
- if (TryGetTargetPosition(out Vector3 targetPos))
+ if (TryGetTargetPosition(out Vector3 targetPos, out float attention))
{
lastSeenTime = Time.time;
- desired = ComputeAngles(targetPos);
+ desired = ComputeAngles(targetPos) * attention;
}
else if (Time.time - lastSeenTime < returnDelay)
{
@@ -133,9 +152,11 @@ void LateUpdate()
ApplyRotation();
}
- bool TryGetTargetPosition(out Vector3 worldPos)
+ /// 0 = 관심 없음(정면), 1 = 완전히 쳐다봄
+ bool TryGetTargetPosition(out Vector3 worldPos, out float attention)
{
worldPos = default;
+ attention = 1f;
if (!followCursor)
{
@@ -149,6 +170,14 @@ bool TryGetTargetPosition(out Vector3 worldPos)
System.IntPtr hwnd = window != null ? window.Hwnd : System.IntPtr.Zero;
if (!DesktopCursor.TryGetScreenPosition(hwnd, out Vector2 screenPos)) return false;
+ // 커서가 얼마나 가까운지로 관심도를 정한다. 멀면 굳이 쳐다보지 않는다.
+ Vector2 headScreen = viewCamera.WorldToScreenPoint(head.position);
+ float distance = Vector2.Distance(screenPos, headScreen);
+
+ float inner = attentionRadius * characterScreenHeight;
+ float outer = inner + Mathf.Max(1f, attentionFalloff * characterScreenHeight);
+ attention = 1f - Mathf.SmoothStep(0f, 1f, Mathf.InverseLerp(inner, outer, distance));
+
// 머리와 같은 깊이 평면에 커서를 투영한다.
Vector3 camForward = viewCamera.transform.forward;
float depth = Vector3.Dot(head.position - viewCamera.transform.position, camForward);
diff --git a/Assets/02_Scripts/Character/WindowClimber.cs b/Assets/02_Scripts/Character/WindowClimber.cs
new file mode 100644
index 0000000..ebf1200
--- /dev/null
+++ b/Assets/02_Scripts/Character/WindowClimber.cs
@@ -0,0 +1,420 @@
+using UnityEngine;
+
+/// 캐릭터의 이동 상태. 나중에 애니메이션 클립을 이 상태에 물리면 된다.
+public enum ClimbState
+{
+ Falling, // 낙하 중
+ Standing, // 발판 위에 서 있음
+ Walking, // 발판 위를 걷는 중 (목표 X 로 이동)
+ Climbing, // 다른 발판으로 올라가는 중
+}
+
+///
+/// 캐릭터를 창 위에 올려놓고, 중력·착지·탑승·자동 올라타기를 처리한다.
+///
+/// 모든 계산은 화면 좌표(픽셀)로 한다. 발판 스캐너가 화면 좌표를 주고, 화면 제한도
+/// 화면 좌표로 하므로 일관되게 맞춘다. 마지막에 한 번만 월드 좌표로 변환한다.
+///
+/// 자동 올라타기의 판단 기준은 "새 창이 생겼는가"가 아니라 "발판이 새로 생겼거나
+/// 위치가 바뀌었는가"다. 새 창은 대개 최대화 상태로 열려 손이 닿지 않지만, 그 창을
+/// 나중에 줄이거나 옮기면 그때 올라탈 수 있게 되기 때문이다.
+///
+public class WindowClimber : MonoBehaviour
+{
+ [Header("참조")]
+ [SerializeField] DesktopPlatformScanner scanner;
+ [SerializeField] CharacterDragger dragger;
+ [SerializeField] VrmCharacterLoader loader;
+ [SerializeField] Camera viewCamera;
+
+ [Header("물리 (화면 픽셀 기준)")]
+ [Tooltip("초당 낙하 가속도")]
+ [SerializeField] float gravity = 2600f;
+
+ [Tooltip("최대 낙하 속도")]
+ [SerializeField] float maxFallSpeed = 1800f;
+
+ [Tooltip("걷는 속도 (픽셀/초)")]
+ [SerializeField] float walkSpeed = 160f;
+
+ [Header("자동 올라타기")]
+ [SerializeField] bool autoClimb = true;
+
+ [Tooltip("바닥에 있을 때만 올라탄다. 이미 창 위에 있으면 다른 창으로 옮기지 않는다")]
+ [SerializeField] bool stayOnCurrentWindow = true;
+
+ [Tooltip("올라탈 대상을 지금 활성화된 창으로 한정한다. " +
+ "여러 창이 열려 있어도 사용자가 보고 있는 창에만 올라간다")]
+ [SerializeField] bool activeWindowOnly = true;
+
+ [Tooltip("올라갈 수 있는 최대 높이차. 0 이면 제한 없음 — 캐릭터가 화면 안에 들어가기만 하면 올라간다. " +
+ "실제 제약은 머리가 화면 위로 잘리는지(WouldBeClipped)이므로 대개 0 으로 둬도 된다")]
+ [SerializeField] float climbHeightLimit = 0f;
+
+ [Tooltip("이보다 멀리 떨어진 발판은 포기한다")]
+ [SerializeField] float maxClimbDistance = 1000f;
+
+ [Tooltip("현재 발판보다 이만큼은 높아야 올라갈 가치가 있다고 본다")]
+ [SerializeField] float minClimbGain = 60f;
+
+ [Tooltip("올라타기 사이의 최소 간격(초). 창을 만질 때마다 뛰어다니지 않게 한다")]
+ [SerializeField] float climbCooldown = 3f;
+
+ [Tooltip("올라가는 속도 (픽셀/초). 거리에 비례해 시간이 정해진다")]
+ [SerializeField] float climbSpeed = 420f;
+
+ [Header("발판 여백")]
+ [Tooltip("발판 좌우 끝에서 최소한 이만큼 안쪽에 선다. " +
+ "캐릭터 반폭보다 작으면 반폭이 우선 적용된다")]
+ [SerializeField] float platformEdgeMargin = 70f;
+
+ [Tooltip("화면 위쪽 여백. 캐릭터 머리가 이보다 위로 가면 발판에서 뛰어내린다")]
+ [SerializeField] float ceilingMargin = 8f;
+
+ [Header("기타")]
+ [Tooltip("착지 판정 여유. 발판을 살짝 지나쳐도 잡아준다")]
+ [SerializeField] float landTolerance = 24f;
+
+ public ClimbState State { get; private set; } = ClimbState.Falling;
+
+ Transform character;
+ float depth; // 카메라로부터의 깊이. 드래그와 같은 평면을 유지한다.
+ Vector2 screenPos; // 캐릭터 발밑의 화면 좌표
+ float verticalVelocity;
+
+ // 탑승 정보
+ System.IntPtr standingHwnd;
+ float offsetFromWindowLeft;
+ bool hasStanding;
+
+ // 목표
+ float walkTargetX;
+ DesktopPlatform climbTarget;
+ Vector2 climbStart;
+ float climbProgress;
+ float climbDuration;
+
+ float nextClimbAllowedTime;
+
+ // 캐릭터의 화면상 범위(원점 기준 오프셋). 깊이가 고정이라 한 번만 재면 된다.
+ Vector2 charOffMin, charOffMax;
+ bool hasExtents;
+
+ void Awake()
+ {
+ if (scanner == null) scanner = FindFirstObjectByType();
+ if (dragger == null) dragger = FindFirstObjectByType();
+ if (loader == null) loader = FindFirstObjectByType();
+ if (viewCamera == null) viewCamera = Camera.main;
+
+ if (loader != null) loader.Loaded += OnCharacterLoaded;
+ if (scanner != null) scanner.Rescanned += OnRescanned;
+ if (dragger != null) dragger.DragEnded += OnDragEnded;
+ }
+
+ void OnDestroy()
+ {
+ if (loader != null) loader.Loaded -= OnCharacterLoaded;
+ if (scanner != null) scanner.Rescanned -= OnRescanned;
+ if (dragger != null) dragger.DragEnded -= OnDragEnded;
+ }
+
+ void OnCharacterLoaded(ICharacterAvatar avatar)
+ {
+ character = avatar?.Root;
+ if (character == null || viewCamera == null) return;
+
+ depth = Vector3.Dot(character.position - viewCamera.transform.position,
+ viewCamera.transform.forward);
+ screenPos = viewCamera.WorldToScreenPoint(character.position);
+
+ hasExtents = CharacterScreenBounds.TryMeasure(viewCamera, character,
+ out charOffMin, out charOffMax);
+ EnterFalling();
+ }
+
+ void OnDragEnded(Transform t)
+ {
+ // 손에서 놓으면 그 자리에서 다시 떨어진다.
+ if (character == null) return;
+ screenPos = viewCamera.WorldToScreenPoint(character.position);
+ EnterFalling();
+ }
+
+ void LateUpdate()
+ {
+ if (character == null || viewCamera == null) return;
+
+ // 드래그 중에는 사용자가 위치를 정한다. 물리는 쉰다.
+ if (dragger != null && dragger.IsDragging)
+ {
+ screenPos = viewCamera.WorldToScreenPoint(character.position);
+ return;
+ }
+
+ float dt = Time.deltaTime;
+
+ switch (State)
+ {
+ case ClimbState.Falling: TickFalling(dt); break;
+ case ClimbState.Standing: TickStanding(dt); break;
+ case ClimbState.Walking: TickWalking(dt); break;
+ case ClimbState.Climbing: TickClimbing(dt); break;
+ }
+
+ Apply();
+ }
+
+ // ---------------- 상태별 처리 ----------------
+
+ void EnterFalling()
+ {
+ State = ClimbState.Falling;
+ verticalVelocity = 0f;
+ hasStanding = false;
+ }
+
+ void TickFalling(float dt)
+ {
+ verticalVelocity = Mathf.Max(verticalVelocity - gravity * dt, -maxFallSpeed);
+
+ float prevY = screenPos.y;
+ screenPos.y += verticalVelocity * dt;
+
+ // 이번 프레임에 지나친 발판이 있으면 거기에 착지한다.
+ // 위치만 비교하면 빠른 낙하에서 발판을 뚫고 지나간다.
+ if (scanner != null && TryFindCrossedPlatform(prevY, screenPos.y, out var landed))
+ {
+ Land(landed);
+ return;
+ }
+
+ // 화면 아래로 빠지면 바닥으로 되돌린다.
+ if (screenPos.y < -200f) screenPos.y = Screen.height * 0.5f;
+ }
+
+ bool TryFindCrossedPlatform(float fromY, float toY, out DesktopPlatform result)
+ {
+ result = default;
+ bool any = false;
+
+ foreach (var p in scanner.Platforms)
+ {
+ if (screenPos.x < p.XMin || screenPos.x > p.XMax) continue;
+
+ // 위에서 아래로 내려오면서 발판 높이를 통과했는가
+ if (p.Y > fromY + landTolerance) continue;
+ if (p.Y < toY - landTolerance) continue;
+ if (WouldBeClipped(p.Y)) continue; // 서면 머리가 잘리는 발판
+
+ if (any && p.Y <= result.Y) continue; // 여러 개면 가장 높은 것
+ result = p;
+ any = true;
+ }
+ return any;
+ }
+
+ void Land(DesktopPlatform p)
+ {
+ screenPos.y = p.Y;
+ verticalVelocity = 0f;
+
+ standingHwnd = p.Hwnd;
+ offsetFromWindowLeft = screenPos.x - p.WindowLeft;
+ hasStanding = true;
+
+ State = ClimbState.Standing;
+ }
+
+ void TickStanding(float dt)
+ {
+ // 서 있는 동안 할 일은 없다. 탑승 갱신은 스캔 시점에 처리한다.
+ }
+
+ void TickWalking(float dt)
+ {
+ float dir = Mathf.Sign(walkTargetX - screenPos.x);
+ screenPos.x += dir * walkSpeed * dt;
+
+ if (Mathf.Abs(walkTargetX - screenPos.x) <= walkSpeed * dt)
+ {
+ screenPos.x = walkTargetX;
+ BeginClimbArc();
+ }
+ }
+
+ void BeginClimbArc()
+ {
+ climbStart = screenPos;
+ climbProgress = 0f;
+
+ float distance = Vector2.Distance(climbStart, new Vector2(ClampToPlatform(climbTarget), climbTarget.Y));
+ climbDuration = Mathf.Max(0.25f, distance / Mathf.Max(1f, climbSpeed));
+
+ State = ClimbState.Climbing;
+ }
+
+ void TickClimbing(float dt)
+ {
+ climbProgress += dt / climbDuration;
+
+ float t = Mathf.Clamp01(climbProgress);
+ float targetX = ClampToPlatform(climbTarget);
+
+ // 수평은 일정하게, 수직은 뒤로 갈수록 느려지게 해서 "올라타는" 느낌을 준다.
+ screenPos.x = Mathf.Lerp(climbStart.x, targetX, t);
+ screenPos.y = Mathf.Lerp(climbStart.y, climbTarget.Y, Mathf.SmoothStep(0f, 1f, t));
+
+ if (t >= 1f) Land(climbTarget);
+ }
+
+ /// 캐릭터 반폭과 설정 여백 중 큰 쪽. 몸이 발판 밖으로 걸치지 않게 한다.
+ float EdgeMargin()
+ {
+ float halfWidth = hasExtents ? (charOffMax.x - charOffMin.x) * 0.5f : 0f;
+ return Mathf.Max(platformEdgeMargin, halfWidth);
+ }
+
+ ///
+ /// 이 높이의 발판에 서면 머리가 화면 위로 잘리는지.
+ /// 창을 위로 끌어올리면 발판도 따라 올라가는데, 그대로 두면 캐릭터가
+ /// 화면을 뚫고 나간다. 그전에 뛰어내리게 하기 위한 판정.
+ ///
+ bool WouldBeClipped(float platformY)
+ {
+ if (!hasExtents) return false;
+ return platformY + charOffMax.y > Screen.height - ceilingMargin;
+ }
+
+ /// 발판 위에서 설 수 있는 X 범위로 제한한다. 너무 좁으면 가운데로.
+ float ClampToPlatform(DesktopPlatform p)
+ {
+ float margin = EdgeMargin();
+ float lo = p.XMin + margin;
+ float hi = p.XMax - margin;
+ if (lo > hi) return p.Center;
+ return Mathf.Clamp(screenPos.x, lo, hi);
+ }
+
+ // ---------------- 스캔 반응 ----------------
+
+ void OnRescanned()
+ {
+ if (character == null || scanner == null) return;
+ if (dragger != null && dragger.IsDragging) return;
+
+ if (State == ClimbState.Standing)
+ {
+ UpdateRiding();
+ if (autoClimb) ConsiderClimb();
+ }
+ }
+
+ /// 서 있는 창이 움직이면 캐릭터도 따라 움직인다. 자리가 사라지면 떨어진다.
+ void UpdateRiding()
+ {
+ if (!hasStanding) return;
+ if (standingHwnd == System.IntPtr.Zero) return; // 바닥은 움직이지 않는다
+
+ DesktopPlatform? match = null;
+ foreach (var p in scanner.Platforms)
+ {
+ if (p.Hwnd != standingHwnd) continue;
+
+ float wanted = p.WindowLeft + offsetFromWindowLeft;
+ if (wanted >= p.XMin && wanted <= p.XMax) { match = p; break; }
+ }
+
+ if (match == null)
+ {
+ // 창이 닫혔거나, 다른 창에 가려져 설 자리가 없어졌다.
+ EnterFalling();
+ return;
+ }
+
+ var m = match.Value;
+
+ // 창을 위로 계속 끌어올리면 캐릭터가 화면 밖으로 밀려난다. 그전에 뛰어내린다.
+ if (WouldBeClipped(m.Y))
+ {
+ EnterFalling();
+ return;
+ }
+
+ screenPos.x = m.WindowLeft + offsetFromWindowLeft;
+ screenPos.y = m.Y;
+ }
+
+ ///
+ /// 올라탈 곳을 고른다. 규칙은 두 가지다.
+ /// - 이미 창 위에 서 있으면 옮기지 않는다. 창을 옮길 때마다 캐릭터가
+ /// 따라다니면 정신없고, 사용자가 올려둔 자리를 존중하는 편이 낫다.
+ /// - 바닥에 있을 때는 "지금 활성화된 창"에만 올라간다. 창이 여러 개 열려
+ /// 있을 때 엉뚱한 배경 창으로 올라가는 것을 막는다.
+ ///
+ void ConsiderClimb()
+ {
+ if (Time.unscaledTime < nextClimbAllowedTime) return;
+
+ // 이미 창 위 — 그대로 둔다. (바닥은 Hwnd 가 Zero 라 여기 걸리지 않는다)
+ if (stayOnCurrentWindow && hasStanding && standingHwnd != System.IntPtr.Zero) return;
+
+ System.IntPtr active = activeWindowOnly ? Win32.GetForegroundWindow() : System.IntPtr.Zero;
+
+ bool found = false;
+ float bestScore = float.MaxValue;
+ DesktopPlatform best = default;
+
+ foreach (var p in scanner.Platforms)
+ {
+ if (p.IsFloor) continue;
+ if (activeWindowOnly && p.Hwnd != active) continue;
+
+ float gain = p.Y - screenPos.y;
+ if (gain < minClimbGain) continue; // 지금보다 충분히 높지 않다
+ if (climbHeightLimit > 0f && gain > climbHeightLimit) continue;
+ if (WouldBeClipped(p.Y)) continue; // 올라가봐야 머리가 잘린다
+
+ float margin = EdgeMargin();
+ if (p.Width < margin * 2f) continue; // 캐릭터가 설 만큼 넓지 않다
+
+ float targetX = Mathf.Clamp(screenPos.x, p.XMin + margin, p.XMax - margin);
+ float horizontal = Mathf.Abs(targetX - screenPos.x);
+ if (horizontal > maxClimbDistance) continue;
+
+ // 가로 이동이 적은 쪽을 우선한다. 높이는 약하게만 반영해서,
+ // 화면에 들어가기만 하면 높은 곳도 후보로 남게 한다.
+ float score = horizontal + gain * 0.15f;
+ if (found && score >= bestScore) continue;
+
+ best = p;
+ bestScore = score;
+ found = true;
+ }
+
+ if (!found) return;
+
+ climbTarget = best;
+ walkTargetX = ClampToPlatform(best);
+ nextClimbAllowedTime = Time.unscaledTime + climbCooldown;
+
+ State = Mathf.Abs(walkTargetX - screenPos.x) > 2f ? ClimbState.Walking : ClimbState.Climbing;
+ if (State == ClimbState.Climbing) BeginClimbArc();
+ }
+
+ // ---------------- 반영 ----------------
+
+ void Apply()
+ {
+ // 화면 좌우로 잘리지 않게 제한한다. 드래그와 같은 규칙.
+ // Y 는 제한하지 않는다. 낙하와 착지 높이를 물리가 정해야 하기 때문이다.
+ // 대신 WouldBeClipped 로 위쪽 잘림을 사전에 막는다.
+ if (hasExtents)
+ {
+ screenPos.x = CharacterScreenBounds.Clamp(screenPos, charOffMin, charOffMax).x;
+ }
+
+ character.position = viewCamera.ScreenToWorldPoint(
+ new Vector3(screenPos.x, screenPos.y, depth));
+ }
+}
diff --git a/Assets/02_Scripts/Character/WindowClimber.cs.meta b/Assets/02_Scripts/Character/WindowClimber.cs.meta
new file mode 100644
index 0000000..8042206
--- /dev/null
+++ b/Assets/02_Scripts/Character/WindowClimber.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a3b279de6756ff64e81895090bb5c3cd
\ No newline at end of file
diff --git a/Assets/02_Scripts/Desktop/ClickThroughHitTest.cs b/Assets/02_Scripts/Desktop/ClickThroughHitTest.cs
index c6f1aee..7abb8ed 100644
--- a/Assets/02_Scripts/Desktop/ClickThroughHitTest.cs
+++ b/Assets/02_Scripts/Desktop/ClickThroughHitTest.cs
@@ -39,9 +39,12 @@ public class ClickThroughHitTest : MonoBehaviour
[Header("검증용")]
[SerializeField] bool showDebugHud = true;
- [Tooltip("커서가 캐릭터 위에 있으면 살짝 커지게 해서 히트테스트를 눈으로 확인")]
+ [Tooltip("커서가 캐릭터 위에 있을 때 미세하게 커지는 반응. 과하면 화면 제한 계산에도 영향을 준다")]
[SerializeField] bool visualizeHover = true;
- [SerializeField] float hoverScale = 1.15f;
+
+ [Tooltip("호버 시 확대 배율. 1.03 정도가 자연스럽다. 값이 크면 드래그 시 화면 가장자리까지 못 간다")]
+ [Range(1f, 1.2f)]
+ [SerializeField] float hoverScale = 1.03f;
#pragma warning restore 0414
@@ -53,6 +56,15 @@ public class ClickThroughHitTest : MonoBehaviour
/// 현재 OS 커서가 캐릭터 위에 있는지.
public bool IsOverCharacter { get; private set; }
+ ///
+ /// true 인 동안 커서 위치와 무관하게 클릭을 받는다.
+ ///
+ /// 드래그 중에는 커서가 캐릭터를 벗어나기 마련인데, 그때 클릭 통과가 다시 켜지면
+ /// 마우스 입력이 뒤 창으로 새면서 드래그가 끊긴다. 드래그 같은 연속 조작은
+ /// 이 스위치로 히트테스트를 잠시 무력화한다.
+ ///
+ public bool ForceInteractive { get; set; }
+
int clickCount;
uint currentExStyle;
string lastAction = "-";
@@ -83,8 +95,14 @@ void Update()
PollNoActivateToggle();
IsOverCharacter = HitTest(out var hit);
- SetClickThrough(!IsOverCharacter);
- UpdateHoverVisual(IsOverCharacter ? hit.transform : null);
+ SetClickThrough(!IsOverCharacter && !ForceInteractive);
+
+ // 드래그 중에는 커서가 캐릭터를 벗어나도 호버 표시를 유지한다.
+ // 화면 끝까지 끌면 캐릭터는 제한에 걸려 멈추고 커서만 더 나가는데,
+ // 그때 크기가 원래대로 돌아가면 손에서 놓친 것처럼 보인다.
+ Transform hoverTarget = IsOverCharacter ? hit.transform
+ : (ForceInteractive ? hovered : null);
+ UpdateHoverVisual(hoverTarget);
var mouse = Mouse.current;
if (IsOverCharacter && mouse != null && mouse.leftButton.wasPressedThisFrame)
diff --git a/Assets/02_Scripts/Desktop/DesktopCursor.cs b/Assets/02_Scripts/Desktop/DesktopCursor.cs
index ec3f122..c187119 100644
--- a/Assets/02_Scripts/Desktop/DesktopCursor.cs
+++ b/Assets/02_Scripts/Desktop/DesktopCursor.cs
@@ -12,6 +12,52 @@
///
public static class DesktopCursor
{
+ ///
+ /// 데스크톱 물리 좌표(Y 아래로) <-> Unity 화면 좌표(Y 위로) 변환에 필요한 정보.
+ /// 창 사각형을 매번 다시 묻지 않도록 한 번 만들어 재사용한다.
+ ///
+ public struct Mapping
+ {
+ public int Left, Top, Width, Height;
+ public float ScaleX, ScaleY;
+
+ public float ToScreenX(float desktopX) => (desktopX - Left) * ScaleX;
+ public float ToScreenY(float desktopY) => (Height - (desktopY - Top)) * ScaleY;
+ public Vector2 ToScreen(float desktopX, float desktopY)
+ => new Vector2(ToScreenX(desktopX), ToScreenY(desktopY));
+ }
+
+ /// 우리 창을 기준으로 한 좌표 변환 정보를 구한다.
+ public static bool TryGetMapping(System.IntPtr hwnd, out Mapping mapping)
+ {
+ mapping = default;
+
+#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
+ if (hwnd == System.IntPtr.Zero) return false;
+ if (!Win32.GetWindowRect(hwnd, out var rc)) return false;
+ if (rc.Width <= 0 || rc.Height <= 0) return false;
+
+ mapping = new Mapping
+ {
+ Left = rc.left,
+ Top = rc.top,
+ Width = rc.Width,
+ Height = rc.Height,
+ // 창 크기와 백버퍼 크기가 어긋날 경우를 대비해 비율로 환산한다.
+ ScaleX = Screen.width / (float)rc.Width,
+ ScaleY = Screen.height / (float)rc.Height,
+ };
+ return true;
+#else
+ mapping = new Mapping
+ {
+ Left = 0, Top = 0, Width = Screen.width, Height = Screen.height,
+ ScaleX = 1f, ScaleY = 1f,
+ };
+ return true;
+#endif
+ }
+
///
/// 커서의 Unity 화면 좌표(좌하단 원점)를 구한다. 커서가 창 밖이면 false.
///
@@ -20,22 +66,14 @@ public static bool TryGetScreenPosition(System.IntPtr hwnd, out Vector2 screenPo
screenPos = default;
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
- if (hwnd == System.IntPtr.Zero) return false;
if (!Win32.GetCursorPos(out var pt)) return false;
- if (!Win32.GetWindowRect(hwnd, out var rc)) return false;
+ if (!TryGetMapping(hwnd, out Mapping map)) return false;
- int w = rc.Width, h = rc.Height;
- if (w <= 0 || h <= 0) return false;
+ // 창 밖이면 무효로 본다.
+ if (pt.x < map.Left || pt.y < map.Top ||
+ pt.x >= map.Left + map.Width || pt.y >= map.Top + map.Height) 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 화면 좌표로. 창 크기와 백버퍼 크기가 어긋날 경우를 대비해 비율 환산.
- screenPos = new Vector2(
- localX * (Screen.width / (float)w),
- (h - localY) * (Screen.height / (float)h));
+ screenPos = map.ToScreen(pt.x, pt.y);
return true;
#else
// 에디터에서는 일반 마우스 입력으로 대체해 미리보기가 가능하게 한다.
diff --git a/Assets/02_Scripts/Desktop/DesktopPlatformScanner.cs b/Assets/02_Scripts/Desktop/DesktopPlatformScanner.cs
new file mode 100644
index 0000000..7717553
--- /dev/null
+++ b/Assets/02_Scripts/Desktop/DesktopPlatformScanner.cs
@@ -0,0 +1,263 @@
+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;
+
+ [Header("스캔")]
+ [Tooltip("창 목록을 다시 훑는 주기(초). 매 프레임 돌 필요가 없다")]
+ [SerializeField] float scanInterval = 0.25f;
+
+ [Tooltip("이보다 좁은 구간은 발판으로 쓰지 않는다(픽셀)")]
+ [SerializeField] float minPlatformWidth = 80f;
+
+ [Tooltip("작업 영역 바닥(작업표시줄 위)을 항상 발판으로 포함한다")]
+ [SerializeField] bool includeFloor = true;
+
+ 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;
+ }
+
+#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)) 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
+}
diff --git a/Assets/02_Scripts/Desktop/DesktopPlatformScanner.cs.meta b/Assets/02_Scripts/Desktop/DesktopPlatformScanner.cs.meta
new file mode 100644
index 0000000..dff49da
--- /dev/null
+++ b/Assets/02_Scripts/Desktop/DesktopPlatformScanner.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 6d8f8663c6576b24db15dac6f3639733
\ No newline at end of file
diff --git a/Assets/02_Scripts/Desktop/PlatformDebugOverlay.cs b/Assets/02_Scripts/Desktop/PlatformDebugOverlay.cs
new file mode 100644
index 0000000..746ed13
--- /dev/null
+++ b/Assets/02_Scripts/Desktop/PlatformDebugOverlay.cs
@@ -0,0 +1,73 @@
+using UnityEngine;
+
+///
+/// 탐지된 발판을 화면에 선으로 그린다.
+///
+/// 상태머신을 얹기 전에 좌표가 맞는지 눈으로 확인하기 위한 것이다. 물리부터
+/// 붙이면 "캐릭터가 이상한 데 선다"가 좌표 문제인지 물리 문제인지 구분되지 않는다.
+/// 검증이 끝나면 꺼두면 된다.
+///
+[RequireComponent(typeof(DesktopPlatformScanner))]
+public class PlatformDebugOverlay : MonoBehaviour
+{
+ [SerializeField] bool show = true;
+ [SerializeField] float lineThickness = 3f;
+ [SerializeField] Color windowColor = new Color(0.2f, 0.9f, 1f, 0.85f);
+ [SerializeField] Color floorColor = new Color(1f, 0.8f, 0.2f, 0.85f);
+
+ [Tooltip("각 발판에 창 제목을 함께 표시")]
+ [SerializeField] bool showTitles = true;
+
+ DesktopPlatformScanner scanner;
+ Texture2D pixel;
+ GUIStyle labelStyle;
+
+ void Awake()
+ {
+ scanner = GetComponent();
+
+ pixel = new Texture2D(1, 1);
+ pixel.SetPixel(0, 0, Color.white);
+ pixel.Apply();
+ }
+
+ void OnDestroy()
+ {
+ if (pixel != null) Destroy(pixel);
+ }
+
+ void OnGUI()
+ {
+ if (!show || scanner == null) return;
+
+ labelStyle ??= new GUIStyle(GUI.skin.label) { fontSize = 11 };
+
+ var platforms = scanner.Platforms;
+
+ for (int i = 0; i < platforms.Count; i++)
+ {
+ var p = platforms[i];
+
+ // OnGUI 는 좌상단 원점이라 Y 를 뒤집는다.
+ float guiY = Screen.height - p.Y;
+
+ GUI.color = p.IsFloor ? floorColor : windowColor;
+ GUI.DrawTexture(new Rect(p.XMin, guiY - lineThickness * 0.5f, p.Width, lineThickness), pixel);
+
+ // 구간 양끝을 세로 눈금으로 표시해 어디서 끊겼는지 보이게 한다.
+ GUI.DrawTexture(new Rect(p.XMin, guiY - 8f, 2f, 16f), pixel);
+ GUI.DrawTexture(new Rect(p.XMax - 2f, guiY - 8f, 2f, 16f), pixel);
+
+ if (showTitles)
+ {
+ GUI.color = Color.black;
+ GUI.Label(new Rect(p.XMin + 5f, guiY + 2f, 400f, 18f), p.Title, labelStyle);
+ GUI.color = p.IsFloor ? floorColor : windowColor;
+ GUI.Label(new Rect(p.XMin + 4f, guiY + 1f, 400f, 18f), p.Title, labelStyle);
+ }
+ }
+
+ GUI.color = Color.white;
+ GUI.Label(new Rect(14f, 190f, 500f, 20f), $"발판 {platforms.Count}개", labelStyle);
+ }
+}
diff --git a/Assets/02_Scripts/Desktop/PlatformDebugOverlay.cs.meta b/Assets/02_Scripts/Desktop/PlatformDebugOverlay.cs.meta
new file mode 100644
index 0000000..e6979eb
--- /dev/null
+++ b/Assets/02_Scripts/Desktop/PlatformDebugOverlay.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 780394ab1bb67834083e97dcb5dea7bd
\ No newline at end of file
diff --git a/Assets/02_Scripts/Desktop/TransparentWindow.cs b/Assets/02_Scripts/Desktop/TransparentWindow.cs
index 86cc32e..35c6995 100644
--- a/Assets/02_Scripts/Desktop/TransparentWindow.cs
+++ b/Assets/02_Scripts/Desktop/TransparentWindow.cs
@@ -14,9 +14,24 @@
[RequireComponent(typeof(Camera))]
public class TransparentWindow : MonoBehaviour
{
+ public enum CoverageMode
+ {
+ /// 플레이어 설정의 기본 해상도를 그대로 쓴다. 디버깅용.
+ FixedSize,
+
+ /// 주 모니터를 꽉 채운다. 해상도가 달라도 자동으로 맞춘다.
+ PrimaryMonitor,
+
+ /// 모든 모니터를 합친 가상 데스크톱 전체를 덮는다.
+ VirtualDesktop,
+ }
+
[Header("창 동작")]
- [Tooltip("창을 가상 데스크톱 전체로 확장. 클릭 통과가 동작하는 것을 확인한 뒤 켤 것")]
- [SerializeField] bool spanVirtualDesktop = false;
+ [Tooltip("창이 덮을 범위. 해상도가 바뀌면 자동으로 다시 맞춘다")]
+ [SerializeField] CoverageMode coverage = CoverageMode.PrimaryMonitor;
+
+ [Tooltip("해상도/모니터 변경을 확인하는 주기(초). 0 이면 확인하지 않는다")]
+ [SerializeField] float boundsCheckInterval = 2f;
[Tooltip("작업표시줄 / Alt+Tab 에서 숨김 (WS_EX_TOOLWINDOW)")]
[SerializeField] bool hideFromTaskbar = true;
@@ -70,19 +85,74 @@ void Update()
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()
{
- int x = 0, y = 0, w = Screen.width, h = Screen.height;
+ applying = true;
- if (spanVirtualDesktop)
+ 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))
{
- 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);
}
@@ -123,7 +193,8 @@ IEnumerator ApplyWindowStyle()
Hwnd = hwnd;
IsReady = true;
- Debug.Log($"[TransparentWindow] 적용 완료: {x},{y} {w}x{h}");
+ applying = false;
+ Debug.Log($"[TransparentWindow] 적용 완료: {coverage} {x},{y} {w}x{h}");
}
#endif
}
diff --git a/Assets/02_Scripts/Desktop/Win32.cs b/Assets/02_Scripts/Desktop/Win32.cs
index 72b9f93..0282b6c 100644
--- a/Assets/02_Scripts/Desktop/Win32.cs
+++ b/Assets/02_Scripts/Desktop/Win32.cs
@@ -30,14 +30,23 @@ internal static class Win32
public const uint SWP_FRAMECHANGED = 0x0020;
public const uint SWP_SHOWWINDOW = 0x0040;
+ public const int SM_CXSCREEN = 0; // 주 모니터 너비
+ public const int SM_CYSCREEN = 1; // 주 모니터 높이
public const int SM_XVIRTUALSCREEN = 76;
public const int SM_YVIRTUALSCREEN = 77;
public const int SM_CXVIRTUALSCREEN = 78;
public const int SM_CYVIRTUALSCREEN = 79;
+ // DwmGetWindowAttribute 속성 번호
+ public const int DWMWA_EXTENDED_FRAME_BOUNDS = 9; // 보이는 실제 프레임(투명 여백 제외)
+ public const int DWMWA_CLOAKED = 14; // UWP 등 "숨겨졌지만 살아있는" 창
+
+ public const uint SPI_GETWORKAREA = 0x0030;
+
public const int SW_HIDE = 0;
public const int SW_SHOW = 5;
+ public const int VK_LBUTTON = 0x01;
public const int VK_SHIFT = 0x10;
public const int VK_CONTROL = 0x11;
public const int VK_MENU = 0x12; // Alt
@@ -67,6 +76,7 @@ public struct RECT
}
[DllImport("user32.dll")] public static extern IntPtr GetActiveWindow();
+ [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); // 지금 활성화된 창
[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);
@@ -76,6 +86,22 @@ public struct RECT
[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);
+
+ // --- 창 열거 ---
+ public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
+
+ [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
+ [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
+ [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd); // 최소화 여부
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowTextLengthW(IntPtr hWnd);
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr hWnd, System.Text.StringBuilder lpString, int nMaxCount);
+ [DllImport("user32.dll")] public static extern bool SystemParametersInfoW(uint uiAction, uint uiParam, out RECT pvParam, uint fWinIni);
+
+ /// DWMWA_CLOAKED 등 int 값을 받는 속성용.
+ [DllImport("dwmapi.dll")] public static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out int pvAttribute, int cbAttribute);
+
+ /// DWMWA_EXTENDED_FRAME_BOUNDS 처럼 RECT 를 받는 속성용.
+ [DllImport("dwmapi.dll")] public static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out RECT pvAttribute, int cbAttribute);
[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);