Files
2026-09-01 21:36:23 +09:00

223 lines
7.7 KiB
C#

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
/// <summary>
/// 캐릭터를 우클릭하면 뜨는 포즈 목록. 항목을 누르면 그 포즈가 된다.
///
/// 이전에는 우클릭할 때마다 다음 포즈로 넘어갔는데, 그러면 어떤 포즈가 있는지
/// 보이지 않고 원하는 것까지 여러 번 눌러야 한다. 무엇보다 목록에 없는 포즈는
/// 도달할 방법이 아예 없어서(예: 가림 포즈만 따로 설정한 경우) 조용히 안 되는
/// 상황이 생긴다. 목록을 눈에 보이게 하면 그 문제가 사라진다.
///
/// 채팅창과 같은 방식으로 만든다 — 런타임 uGUI, 클릭 통과 히트테스트에 사각형 등록.
/// 등록하지 않으면 클릭이 뒤 창으로 새어나가 버튼이 눌리지 않는다.
///
/// 키보드 포커스는 필요 없다(마우스만 쓴다). 그래서 채팅창과 달리
/// WS_EX_NOACTIVATE 를 건드리지 않는다.
/// </summary>
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<PoseOption> onPick;
Func<Vector2, bool> hitRegion;
bool prevLeftDown;
public bool IsOpen => panel != null && panel.gameObject.activeSelf;
void Awake()
{
if (hitTest == null) hitTest = FindFirstObjectByType<ClickThroughHitTest>();
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<TransparentWindow>() : null;
return window != null ? window.Hwnd : IntPtr.Zero;
}
}
// ------------------------------------------------------------------ 조작
/// <summary>커서 위치에 목록을 띄운다. 이미 열려 있으면 다시 그린다.</summary>
public void Show(Vector2 screenPos, IReadOnlyList<PoseOption> poses,
Action<PoseOption> 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>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 210; // 채팅창(200) 위에
var scaler = canvasGo.GetComponent<CanvasScaler>();
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<Image>();
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<PoseOption> 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);
}
/// <summary>목록에 보여줄 이름. 열거형 이름을 그대로 쓰면 사용자에게 불친절하다.</summary>
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();
}
}
}