diff --git a/Assets/01_Scenes/MainScene.unity b/Assets/01_Scenes/MainScene.unity
index c052441..125c7f1 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:9b74de853bf429427add4271dc3edb23c65cb210dc7ce0243ee3a75da79fe086
-size 22921
+oid sha256:db4d9d698adf13f8fd8fb761947ae805f209668b974f1a8a9fc2bf6461122265
+size 24910
diff --git a/Assets/02_Scripts/Character/CharacterDragger.cs b/Assets/02_Scripts/Character/CharacterDragger.cs
index 4454014..1af4211 100644
--- a/Assets/02_Scripts/Character/CharacterDragger.cs
+++ b/Assets/02_Scripts/Character/CharacterDragger.cs
@@ -46,6 +46,7 @@ public class CharacterDragger : MonoBehaviour
bool pressing; // 버튼이 눌린 상태(아직 드래그는 아닐 수 있음)
bool dragging; // 임계값을 넘겨 실제 드래그 중
bool prevButtonDown; // 눌린 순간만 잡아내기 위한 직전 프레임 상태
+ bool prevRightDown;
Vector2 pressStartPos;
Vector2 grabScreenOffset; // 캐릭터 원점 - 커서 (화면 좌표)
Vector2 boundsOffMin, boundsOffMax; // 원점 기준 캐릭터의 화면상 범위
@@ -65,6 +66,15 @@ public class CharacterDragger : MonoBehaviour
///
public event System.Action Clicked;
+ ///
+ /// 캐릭터 위에서 오른쪽 버튼을 누른 순간. 포즈 바꾸기 등에 쓴다.
+ ///
+ /// 왼쪽 버튼과 같은 방식으로 OS 에 직접 묻는다. 이 창은 포커스를 받지 않아
+ /// Unity 입력을 신뢰하기 어렵고, 무엇보다 판정에 쓰는 커서 좌표를 이미
+ /// GetCursorPos 로 얻고 있어 두 경로를 섞을 이유가 없다.
+ ///
+ public event System.Action RightClicked;
+
void Awake()
{
hitTest = GetComponent();
@@ -78,11 +88,17 @@ void Update()
{
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
bool buttonDown = Win32.IsKeyDown(Win32.VK_LBUTTON);
+ bool rightDown = Win32.IsKeyDown(Win32.VK_RBUTTON);
#else
var mouse = UnityEngine.InputSystem.Mouse.current;
bool buttonDown = mouse != null && mouse.leftButton.isPressed;
+ bool rightDown = mouse != null && mouse.rightButton.isPressed;
#endif
+ // 오른쪽 버튼은 누른 순간만 본다. 드래그와 달리 이어지는 조작이 없다.
+ if (rightDown && !prevRightDown && hitTest.IsOverCharacter) RightClicked?.Invoke();
+ prevRightDown = rightDown;
+
// 눌린 "순간"만 잡는다. 이미 누른 채로 커서가 캐릭터 위로 지나가는 경우
// (다른 창을 끌고 오다가 캐릭터를 스치는 등)에 잡히면 안 된다.
// 클릭으로 채팅창이 토글되면서부터는 이 오작동이 눈에 띄게 된다.
diff --git a/Assets/02_Scripts/Character/CharacterMotionDirector.cs b/Assets/02_Scripts/Character/CharacterMotionDirector.cs
index ad1937b..53da668 100644
--- a/Assets/02_Scripts/Character/CharacterMotionDirector.cs
+++ b/Assets/02_Scripts/Character/CharacterMotionDirector.cs
@@ -19,6 +19,7 @@ public class CharacterMotionDirector : MonoBehaviour
[Header("참조 (비우면 씬에서 탐색)")]
[SerializeField] VrmCharacterLoader loader;
[SerializeField] WindowClimber climber;
+ [SerializeField] CharacterDragger dragger;
[Header("애니메이션")]
[Tooltip("Humanoid 클립을 담은 Animator Controller. 비우면 ProceduralIdle 로 동작한다")]
@@ -39,28 +40,32 @@ public class CharacterMotionDirector : MonoBehaviour
[SerializeField] string sitState = "Sit";
[SerializeField] string lieDownState = "LieDown";
- [Header("쉬는 자세")]
- [Tooltip("발판에 선 뒤 자세를 바꾸기까지 기다리는 시간의 최소/최대(초). " +
- "매번 다르게 해야 기계적으로 보이지 않는다")]
- [SerializeField] float restDelayMin = 5f;
- [SerializeField] float restDelayMax = 14f;
+ [Header("포즈 (캐릭터를 우클릭하면 목록이 뜬다)")]
+ [Tooltip("비우면 씬에서 탐색. 없으면 우클릭이 다음 포즈로 넘기는 방식으로 대체된다")]
+ [SerializeField] PoseMenuUI poseMenu;
- [Tooltip("서 있을 때 고를 자세들. Idle 을 섞어둬야 계속 누워만 있지 않는다")]
+ [Tooltip("우클릭 목록에 나올 포즈들. 첫 항목이 기본 자세이고, " +
+ "걷거나 떨어졌다 돌아오면 항상 첫 항목으로 되돌아온다. " +
+ "각 항목의 Hide Behind Platform 을 켜면 그 포즈일 때 발판 아래가 가려진다")]
[SerializeField]
- CharacterMotionKind[] restPoses =
+ PoseOption[] poses =
{
- CharacterMotionKind.Idle,
- CharacterMotionKind.Sit,
- CharacterMotionKind.LieDown,
+ new PoseOption { kind = CharacterMotionKind.Idle },
+ new PoseOption { kind = CharacterMotionKind.Sit },
+ new PoseOption { kind = CharacterMotionKind.LieDown },
};
+ [Header("발판 뒤로 숨기기")]
+ [Tooltip("비우면 씬에서 탐색. 없으면 가림 기능만 조용히 꺼진다")]
+ [SerializeField] PlatformOccluder occluder;
+
[Header("동작")]
[Tooltip("클립이 준비되면 ProceduralIdle 을 끈다. 둘 다 같은 본을 써서 함께 두면 싸운다")]
[SerializeField] bool disableProceduralIdle = true;
ICharacterMotion motion;
ClimbState currentState = ClimbState.Falling;
- float nextRestChangeTime;
+ int poseIndex;
Dictionary BuildNames() => new Dictionary
{
@@ -76,6 +81,27 @@ void Awake()
{
if (loader == null) loader = FindFirstObjectByType();
if (climber == null) climber = FindFirstObjectByType();
+ if (dragger == null) dragger = FindFirstObjectByType();
+ if (occluder == null) occluder = FindFirstObjectByType();
+ if (poseMenu == null) poseMenu = FindFirstObjectByType();
+
+ WarnIfOccluderMissing();
+ }
+
+ /// 가림을 켜둔 포즈가 있는데 가림 컴포넌트가 없으면, 조용히 안 되는 대신 알린다.
+ void WarnIfOccluderMissing()
+ {
+ if (poses == null || occluder != null) return;
+
+ foreach (var pose in poses)
+ {
+ if (!pose.hideBehindPlatform) continue;
+
+ Debug.LogWarning($"[CharacterMotionDirector] '{pose.kind}' 에 발판 가림이 켜져 있지만 " +
+ "씬에 PlatformOccluder 가 없어 동작하지 않습니다. " +
+ "메뉴 10 을 다시 실행하세요.");
+ return;
+ }
}
void OnEnable()
@@ -92,25 +118,51 @@ void OnEnable()
climber.StateChanged += OnStateChanged;
currentState = climber.State;
}
+
+ if (dragger != null) dragger.RightClicked += OnRightClicked;
}
void OnDisable()
{
if (loader != null) loader.Loaded -= OnCharacterLoaded;
if (climber != null) climber.StateChanged -= OnStateChanged;
+ if (dragger != null) dragger.RightClicked -= OnRightClicked;
}
- void Update()
+ ///
+ /// 캐릭터를 우클릭하면 포즈 목록을 띄운다.
+ ///
+ /// 서 있을 때만 받는다. 걷거나 떨어지는 중에 눕힐 수는 없다.
+ ///
+ void OnRightClicked()
{
- // 서 있을 때만 자세를 바꾼다. 걷거나 떨어지는 중에 눕게 할 수는 없다.
if (motion == null || currentState != ClimbState.Standing) return;
- if (Time.unscaledTime < nextRestChangeTime) return;
+ if (poses == null || poses.Length == 0) return;
- ScheduleNextRestChange();
-
- if (restPoses != null && restPoses.Length > 0)
+ if (poseMenu == null)
{
- motion.Play(restPoses[Random.Range(0, restPoses.Length)]);
+ // 메뉴가 없으면 최소한 다음 포즈로라도 넘어간다. 아무 반응이 없는 것보다 낫다.
+ poseIndex = (poseIndex + 1) % poses.Length;
+ PlayPose(poses[poseIndex]);
+ return;
+ }
+
+ if (poseMenu.IsOpen)
+ {
+ poseMenu.Hide();
+ return;
+ }
+
+ if (!DesktopCursor.TryGetScreenPosition(CursorHwnd, out Vector2 cursor)) return;
+ poseMenu.Show(cursor, poses, PlayPose);
+ }
+
+ System.IntPtr CursorHwnd
+ {
+ get
+ {
+ var window = FindFirstObjectByType();
+ return window != null ? window.Hwnd : System.IntPtr.Zero;
}
}
@@ -145,24 +197,51 @@ void OnCharacterLoaded(ICharacterAvatar avatar)
}
currentState = climber != null ? climber.State : ClimbState.Standing;
- motion.Play(ToMotionKind(currentState));
- ScheduleNextRestChange();
+ poseIndex = 0;
+ ApplyState(currentState);
}
void OnStateChanged(ClimbState state)
{
currentState = state;
- motion?.Play(ToMotionKind(state));
- // 서기 시작한 시점부터 다시 센다. 착지하자마자 눕지 않게.
- if (state == ClimbState.Standing) ScheduleNextRestChange();
+ // 움직이기 시작하면 고른 포즈는 버린다. 걷다가 다시 서면 기본 자세부터.
+ poseIndex = 0;
+ ApplyState(state);
}
- void ScheduleNextRestChange()
+ ///
+ /// 이동 상태에 맞는 몸짓으로 간다. 서 있는 상태만 "포즈"로 취급해
+ /// 목록의 첫 항목(가림 설정 포함)을 쓰고, 나머지는 그냥 이동 몸짓이다.
+ ///
+ void ApplyState(ClimbState state)
{
- float min = Mathf.Max(0.5f, restDelayMin);
- float max = Mathf.Max(min, restDelayMax);
- nextRestChangeTime = Time.unscaledTime + Random.Range(min, max);
+ if (state == ClimbState.Standing) PlayDefaultPose();
+ else PlayMotion(ToMotionKind(state));
+ }
+
+ ///
+ /// 고른 포즈를 재생하고, 그 포즈의 가림 설정도 함께 반영한다.
+ /// 포즈를 바꾸는 곳이 여러 군데라 한 곳으로 모은다 — 따로 두면 가림만 남는 사고가 난다.
+ ///
+ void PlayPose(PoseOption pose)
+ {
+ motion?.Play(pose.kind);
+ if (occluder != null) occluder.Active = pose.hideBehindPlatform;
+ }
+
+ /// 이동 중의 몸짓. 걷거나 떨어지는 동안에는 가리지 않는다.
+ void PlayMotion(CharacterMotionKind kind)
+ {
+ motion?.Play(kind);
+ if (occluder != null) occluder.Active = false;
+ }
+
+ /// 발판에 섰을 때의 기본 자세. 목록의 첫 항목이다.
+ void PlayDefaultPose()
+ {
+ if (poses != null && poses.Length > 0) PlayPose(poses[0]);
+ else PlayMotion(CharacterMotionKind.Idle);
}
static CharacterMotionKind ToMotionKind(ClimbState state)
diff --git a/Assets/02_Scripts/Character/ICharacterMotion.cs b/Assets/02_Scripts/Character/ICharacterMotion.cs
index 116ecb4..f811755 100644
--- a/Assets/02_Scripts/Character/ICharacterMotion.cs
+++ b/Assets/02_Scripts/Character/ICharacterMotion.cs
@@ -1,3 +1,5 @@
+using UnityEngine;
+
///
/// 캐릭터가 지을 수 있는 몸짓.
///
@@ -15,6 +17,28 @@ public enum CharacterMotionKind
LieDown, // 눕기
}
+///
+/// 우클릭 메뉴에 나올 포즈 하나. 표시 이름과 가림 여부를 함께 들고 다닌다.
+///
+/// 예전에는 "메뉴에 넣을 포즈 목록"과 "가려야 할 포즈 목록"을 따로 뒀는데,
+/// 둘을 손으로 맞춰야 했고 어긋나면(가림 목록에만 있고 메뉴 목록엔 없으면)
+/// 그 포즈에 도달할 방법이 없어 조용히 아무 일도 안 일어났다.
+/// 한 곳에 묶으면 그 상태 자체가 만들어질 수 없다.
+///
+[System.Serializable]
+public struct PoseOption
+{
+ [Tooltip("어떤 몸짓인지")]
+ public CharacterMotionKind kind;
+
+ [Tooltip("메뉴에 표시할 이름. 비우면 기본 이름을 쓴다")]
+ public string label;
+
+ [Tooltip("켜면 이 포즈일 때 발판 아래를 가린다 — 캐릭터가 창 뒤에 있는 것처럼 보인다. " +
+ "예: 창 밑에서 머리만 내미는 포즈")]
+ public bool hideBehindPlatform;
+}
+
///
/// 캐릭터 몸짓을 재생하는 계층의 창구.
///
diff --git a/Assets/02_Scripts/Character/PlatformOccluder.cs b/Assets/02_Scripts/Character/PlatformOccluder.cs
new file mode 100644
index 0000000..d6a86df
--- /dev/null
+++ b/Assets/02_Scripts/Character/PlatformOccluder.cs
@@ -0,0 +1,136 @@
+using UnityEngine;
+
+///
+/// 발판 아래에 깊이만 기록하는 사각형을 깔아 캐릭터를 가린다.
+/// "발판(=실제 창) 뒤에 서 있는" 그림을 만들 때 쓴다. 예: 창 밑에서 머리만 내미는 포즈.
+///
+/// 왜 렌더 순서로 해결되지 않는가:
+/// 발판은 Unity 오브젝트가 아니라 진짜 데스크톱 창이고, 우리 창은 그 위에 떠 있는
+/// 최상위 오버레이다. 그래서 "창 뒤에 그린다"가 물리적으로 불가능하다.
+/// 대신 그 영역에서 캐릭터를 아예 그리지 않으면, 우리 창은 그 자리에 투명한 채로
+/// 남고 진짜 창이 비쳐 보인다. 결과가 같다.
+///
+/// 방법은 깊이 마스크다. 색을 안 쓰고(ColorMask 0) 깊이만 쓰는 사각형을
+/// 캐릭터보다 카메라 가까이, 캐릭터보다 먼저(Queue Geometry-100) 그리면
+/// 그 뒤의 캐릭터 픽셀이 깊이 테스트에서 탈락한다. 알파를 0 으로 덮는 방법과 달리
+/// 같은 자리에 그려진 다른 것(채팅창 등)을 건드리지 않는다.
+///
+[DefaultExecutionOrder(100)] // WindowClimber(LateUpdate) 가 위치를 정한 뒤에 따라간다
+public class PlatformOccluder : MonoBehaviour
+{
+ [Header("참조 (비우면 씬에서 탐색)")]
+ [SerializeField] WindowClimber climber;
+ [SerializeField] Camera viewCamera;
+
+ [Tooltip("MyCharacterAgent/PlatformOccluder 셰이더. " +
+ "직접 참조해야 빌드에서 셰이더가 빠지지 않는다")]
+ [SerializeField] Shader occluderShader;
+
+ [Header("동작")]
+ [Tooltip("캐릭터보다 카메라 쪽으로 얼마나 앞에 둘지(월드 단위). " +
+ "너무 작으면 깊이 정밀도 문제로 지글거리고, 너무 크면 다른 것까지 가린다")]
+ [SerializeField] float depthBias = 0.05f;
+
+ [Tooltip("발판 위쪽 경계를 얼마나 내릴지(픽셀). 양수면 발판선보다 아래부터 가린다. " +
+ "머리가 살짝 더 보이게 하고 싶을 때 조절한다")]
+ [SerializeField] float topOffset = 0f;
+
+ /// 지금 가려야 하는지. CharacterMotionDirector 가 포즈에 따라 켜고 끈다.
+ public bool Active { get; set; }
+
+ MeshFilter meshFilter;
+ MeshRenderer meshRenderer;
+ Mesh mesh;
+ readonly Vector3[] corners = new Vector3[4];
+
+ void Awake()
+ {
+ if (climber == null) climber = FindFirstObjectByType();
+ if (viewCamera == null) viewCamera = Camera.main;
+
+ BuildQuad();
+ SetVisible(false);
+ }
+
+ void OnDestroy()
+ {
+ if (mesh != null) Destroy(mesh);
+ if (meshRenderer != null && meshRenderer.material != null) Destroy(meshRenderer.material);
+ }
+
+ void LateUpdate()
+ {
+ if (meshRenderer == null) return;
+
+ if (!Active || climber == null || viewCamera == null)
+ {
+ SetVisible(false);
+ return;
+ }
+
+ var platform = climber.CurrentPlatform;
+ if (platform == null)
+ {
+ // 공중에 있는 동안은 가릴 발판이 없다.
+ SetVisible(false);
+ return;
+ }
+
+ UpdateQuad(platform.Value);
+ SetVisible(true);
+ }
+
+ /// 발판의 X 범위 × (발판선 아래 ~ 화면 아래)를 덮는 사각형으로 갱신한다.
+ void UpdateQuad(DesktopPlatform platform)
+ {
+ // 캐릭터와 같은 평면보다 살짝 앞. 여기서 깊이를 써야 캐릭터가 탈락한다.
+ float d = Mathf.Max(0.01f, climber.CharacterDepth - depthBias);
+ float top = platform.Y - topOffset;
+
+ corners[0] = viewCamera.ScreenToWorldPoint(new Vector3(platform.XMin, 0f, d));
+ corners[1] = viewCamera.ScreenToWorldPoint(new Vector3(platform.XMax, 0f, d));
+ corners[2] = viewCamera.ScreenToWorldPoint(new Vector3(platform.XMax, top, d));
+ corners[3] = viewCamera.ScreenToWorldPoint(new Vector3(platform.XMin, top, d));
+
+ // 정점을 월드 좌표로 넣으므로 트랜스폼은 원점/무회전으로 둔다.
+ mesh.SetVertices(corners);
+ mesh.RecalculateBounds();
+ }
+
+ void BuildQuad()
+ {
+ transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity);
+ transform.localScale = Vector3.one;
+
+ mesh = new Mesh { name = "PlatformOccluderQuad" };
+ // 매 프레임 정점이 바뀌므로 표시해 둔다.
+ mesh.MarkDynamic();
+ mesh.SetVertices(corners);
+ mesh.SetTriangles(new[] { 0, 2, 1, 0, 3, 2 }, 0);
+
+ meshFilter = gameObject.GetComponent();
+ if (meshFilter == null) meshFilter = gameObject.AddComponent();
+ meshFilter.sharedMesh = mesh;
+
+ meshRenderer = gameObject.GetComponent();
+ if (meshRenderer == null) meshRenderer = gameObject.AddComponent();
+ meshRenderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
+ meshRenderer.receiveShadows = false;
+ meshRenderer.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
+ meshRenderer.reflectionProbeUsage = UnityEngine.Rendering.ReflectionProbeUsage.Off;
+
+ if (occluderShader == null)
+ {
+ Debug.LogWarning("[PlatformOccluder] 셰이더가 비어 있어 가림이 동작하지 않습니다. " +
+ "MyCharacterAgent/PlatformOccluder 셰이더를 연결하세요.");
+ return;
+ }
+
+ meshRenderer.material = new Material(occluderShader) { name = "PlatformOccluder (runtime)" };
+ }
+
+ void SetVisible(bool value)
+ {
+ if (meshRenderer != null && meshRenderer.enabled != value) meshRenderer.enabled = value;
+ }
+}
diff --git a/Assets/02_Scripts/Character/PlatformOccluder.cs.meta b/Assets/02_Scripts/Character/PlatformOccluder.cs.meta
new file mode 100644
index 0000000..b45367f
--- /dev/null
+++ b/Assets/02_Scripts/Character/PlatformOccluder.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: b3a860401aad71342b84131203d2e1d5
\ No newline at end of file
diff --git a/Assets/02_Scripts/Character/PoseMenuUI.cs b/Assets/02_Scripts/Character/PoseMenuUI.cs
new file mode 100644
index 0000000..8d8f39f
--- /dev/null
+++ b/Assets/02_Scripts/Character/PoseMenuUI.cs
@@ -0,0 +1,222 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+using UnityEngine.UI;
+
+///
+/// 캐릭터를 우클릭하면 뜨는 포즈 목록. 항목을 누르면 그 포즈가 된다.
+///
+/// 이전에는 우클릭할 때마다 다음 포즈로 넘어갔는데, 그러면 어떤 포즈가 있는지
+/// 보이지 않고 원하는 것까지 여러 번 눌러야 한다. 무엇보다 목록에 없는 포즈는
+/// 도달할 방법이 아예 없어서(예: 가림 포즈만 따로 설정한 경우) 조용히 안 되는
+/// 상황이 생긴다. 목록을 눈에 보이게 하면 그 문제가 사라진다.
+///
+/// 채팅창과 같은 방식으로 만든다 — 런타임 uGUI, 클릭 통과 히트테스트에 사각형 등록.
+/// 등록하지 않으면 클릭이 뒤 창으로 새어나가 버튼이 눌리지 않는다.
+///
+/// 키보드 포커스는 필요 없다(마우스만 쓴다). 그래서 채팅창과 달리
+/// WS_EX_NOACTIVATE 를 건드리지 않는다.
+///
+public class PoseMenuUI : MonoBehaviour
+{
+ [Header("모양")]
+ [SerializeField] float menuWidth = 132f;
+ [SerializeField] float itemHeight = 32f;
+ [SerializeField] float padding = 6f;
+ [SerializeField] int fontSize = 13;
+
+ [Header("참조 (비우면 씬에서 탐색)")]
+ [SerializeField] ClickThroughHitTest hitTest;
+
+ Canvas canvas;
+ RectTransform panel;
+ RectTransform itemRoot;
+ Action onPick;
+ Func hitRegion;
+ bool prevLeftDown;
+
+ public bool IsOpen => panel != null && panel.gameObject.activeSelf;
+
+ void Awake()
+ {
+ if (hitTest == null) hitTest = FindFirstObjectByType();
+
+ ChatUiBuilder.EnsureEventSystem();
+ Build();
+ panel.gameObject.SetActive(false);
+ }
+
+ void OnEnable()
+ {
+ if (hitTest != null)
+ {
+ hitRegion = ContainsScreenPoint;
+ hitTest.RegisterInteractiveRegion(hitRegion);
+ }
+ }
+
+ void OnDisable()
+ {
+ if (hitTest != null && hitRegion != null)
+ {
+ hitTest.UnregisterInteractiveRegion(hitRegion);
+ hitRegion = null;
+ }
+ Hide();
+ }
+
+ void Update()
+ {
+ if (!IsOpen) return;
+
+ // 바깥을 누르면 닫는다. 전체 화면 배경판을 깔면 화면 전체가 클릭을 받아버려서
+ // 데스크톱 오버레이로서는 쓸 수 없다. 그래서 직접 커서를 확인한다.
+#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
+ bool leftDown = Win32.IsKeyDown(Win32.VK_LBUTTON);
+#else
+ var mouse = UnityEngine.InputSystem.Mouse.current;
+ bool leftDown = mouse != null && mouse.leftButton.isPressed;
+#endif
+
+ if (leftDown && !prevLeftDown)
+ {
+ if (!DesktopCursor.TryGetScreenPosition(CursorHwnd, out Vector2 cursor) ||
+ !ContainsScreenPoint(cursor))
+ {
+ Hide();
+ }
+ }
+ prevLeftDown = leftDown;
+
+ var keyboard = UnityEngine.InputSystem.Keyboard.current;
+ if (keyboard != null && keyboard.escapeKey.wasPressedThisFrame) Hide();
+ }
+
+ IntPtr CursorHwnd
+ {
+ get
+ {
+ var window = hitTest != null ? hitTest.GetComponent() : null;
+ return window != null ? window.Hwnd : IntPtr.Zero;
+ }
+ }
+
+ // ------------------------------------------------------------------ 조작
+
+ /// 커서 위치에 목록을 띄운다. 이미 열려 있으면 다시 그린다.
+ public void Show(Vector2 screenPos, IReadOnlyList poses,
+ Action pick)
+ {
+ if (panel == null || poses == null || poses.Count == 0) return;
+
+ onPick = pick;
+ Rebuild(poses);
+
+ panel.gameObject.SetActive(true);
+ Place(screenPos);
+
+ // 이번 프레임의 우클릭이 "바깥 클릭"으로 오인되지 않게 한 번 흡수한다.
+ prevLeftDown = true;
+ }
+
+ public void Hide()
+ {
+ if (panel != null) panel.gameObject.SetActive(false);
+ onPick = null;
+ }
+
+ public bool ContainsScreenPoint(Vector2 screenPoint)
+ {
+ if (!IsOpen || panel == null) return false;
+ return RectTransformUtility.RectangleContainsScreenPoint(panel, screenPoint, null);
+ }
+
+ void Place(Vector2 screenPos)
+ {
+ float scale = canvas != null ? canvas.scaleFactor : 1f;
+ Vector2 size = panel.sizeDelta * scale;
+
+ // 커서 오른쪽 아래에 띄우되 화면 밖으로 나가지 않게 한다.
+ float x = Mathf.Clamp(screenPos.x, 0f, Mathf.Max(0f, Screen.width - size.x));
+ float y = Mathf.Clamp(screenPos.y - size.y, 0f, Mathf.Max(0f, Screen.height - size.y));
+
+ panel.anchoredPosition = new Vector2(x, y) / scale;
+ }
+
+ // ------------------------------------------------------------------ 계층
+
+ void Build()
+ {
+ var canvasGo = new GameObject("PoseMenuCanvas", typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster));
+ canvasGo.transform.SetParent(transform, false);
+
+ canvas = canvasGo.GetComponent