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(); canvas.renderMode = RenderMode.ScreenSpaceOverlay; canvas.sortingOrder = 210; // 채팅창(200) 위에 var scaler = canvasGo.GetComponent(); scaler.uiScaleMode = CanvasScaler.ScaleMode.ConstantPixelSize; scaler.scaleFactor = 1f; panel = ChatUiBuilder.NewRect("Panel", canvasGo.transform as RectTransform); panel.anchorMin = Vector2.zero; panel.anchorMax = Vector2.zero; panel.pivot = Vector2.zero; var background = panel.gameObject.AddComponent(); background.sprite = ChatUiTheme.RoundedSmall; background.type = Image.Type.Sliced; background.color = new Color( ChatUiTheme.PanelBackground.r, ChatUiTheme.PanelBackground.g, ChatUiTheme.PanelBackground.b, 0.97f); itemRoot = ChatUiBuilder.NewRect("Items", panel); ChatUiBuilder.Stretch(itemRoot, padding, padding); var layout = ChatUiBuilder.MakeVerticalList(itemRoot, new RectOffset(0, 0, 0, 0), 2f); layout.childForceExpandHeight = false; } void Rebuild(IReadOnlyList poses) { for (int i = itemRoot.childCount - 1; i >= 0; i--) { Destroy(itemRoot.GetChild(i).gameObject); } for (int i = 0; i < poses.Count; i++) { PoseOption pose = poses[i]; string label = string.IsNullOrWhiteSpace(pose.label) ? LabelFor(pose.kind) : pose.label; var button = ChatUiBuilder.NewButton(pose.kind.ToString(), itemRoot, label, fontSize, ChatUiTheme.InputBackground, ChatUiTheme.PrimaryText); ChatUiBuilder.SetHeight(button.transform, itemHeight); button.onClick.AddListener(() => { var callback = onPick; Hide(); callback?.Invoke(pose); }); } panel.sizeDelta = new Vector2( menuWidth, poses.Count * itemHeight + Mathf.Max(0, poses.Count - 1) * 2f + padding * 2f); } /// 목록에 보여줄 이름. 열거형 이름을 그대로 쓰면 사용자에게 불친절하다. static string LabelFor(CharacterMotionKind kind) { switch (kind) { case CharacterMotionKind.Idle: return "서기"; case CharacterMotionKind.Sit: return "걸터앉기"; case CharacterMotionKind.LieDown: return "눕기"; case CharacterMotionKind.Walk: return "걷기"; case CharacterMotionKind.Fall: return "낙하"; case CharacterMotionKind.Jump: return "점프"; default: return kind.ToString(); } } }