204 lines
8.4 KiB
C#
204 lines
8.4 KiB
C#
#nullable enable
|
||
using System.Collections.Generic;
|
||
using UnityEngine.UIElements;
|
||
using UnityEngine;
|
||
|
||
namespace UVC.UIToolkit.Util
|
||
{
|
||
/// <summary>
|
||
/// UI Toolkit 패널 위에 포인터(마우스)가 위치하는지 확인하는 유틸리티 클래스입니다.
|
||
///
|
||
/// <para><b>개요:</b></para>
|
||
/// <para>
|
||
/// Unity의 <see cref="UnityEngine.UIElements.RuntimePanelUtils"/>와 <see cref="IPanel.Pick"/>을 사용하여
|
||
/// 등록된 <see cref="VisualElement"/>의 패널 위에 포인터가 있는지 효율적으로 감지합니다.
|
||
/// uGUI의 <c>EventSystem.IsPointerOverGameObject()</c>는 UI Toolkit에서 동작하지 않으므로
|
||
/// 이 클래스를 대신 사용합니다.
|
||
/// </para>
|
||
///
|
||
/// <para><b>사용 예시:</b></para>
|
||
/// <code>
|
||
/// // 등록
|
||
/// UTKPointerBlocker.Register(myVisualElement);
|
||
/// UTKPointerBlocker.Register(anotherVisualElement);
|
||
///
|
||
/// // 확인
|
||
/// if (UTKPointerBlocker.IsPointerOverUI(Input.mousePosition))
|
||
/// {
|
||
/// return; // UI 위에 있으면 3D 오브젝트 선택 무시
|
||
/// }
|
||
///
|
||
/// // 해제
|
||
/// UTKPointerBlocker.Unregister(myVisualElement);
|
||
/// </code>
|
||
/// </summary>
|
||
public static class UTKPointerBlocker
|
||
{
|
||
/// <summary>
|
||
/// 포인터 감지 대상으로 등록된 VisualElement 목록입니다.
|
||
/// </summary>
|
||
private static readonly List<VisualElement> _registeredElements = new();
|
||
|
||
// 클릭 판단 임계값
|
||
private const float ClickMaxDuration = 0.3f; // 초
|
||
private const float ClickMaxDistance = 10f; // 픽셀
|
||
|
||
// mouseDown=true 시점에 저장되는 상태
|
||
private static float _mouseDownTime;
|
||
private static Vector2 _mouseDownPosition;
|
||
private static bool _mouseDownResult;
|
||
private static bool _hasMouseDownState;
|
||
|
||
/// <summary>
|
||
/// VisualElement를 포인터 감지 대상으로 등록합니다.
|
||
/// 이미 등록된 경우 무시합니다.
|
||
/// </summary>
|
||
/// <param name="element">등록할 VisualElement (일반적으로 rootVisualElement)</param>
|
||
public static void Register(VisualElement element)
|
||
{
|
||
if (!_registeredElements.Contains(element))
|
||
_registeredElements.Add(element);
|
||
}
|
||
|
||
/// <summary>
|
||
/// VisualElement를 포인터 감지 대상에서 제거합니다.
|
||
/// </summary>
|
||
/// <param name="element">제거할 VisualElement</param>
|
||
public static void Unregister(VisualElement element)
|
||
{
|
||
_registeredElements.Remove(element);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 등록된 모든 VisualElement를 제거합니다.
|
||
/// </summary>
|
||
public static void Clear()
|
||
{
|
||
_registeredElements.Clear();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 주어진 스크린 좌표(Unity Input 기준, 좌하단 원점)에서
|
||
/// 등록된 UI Toolkit VisualElement의 자식 UI 위에 포인터가 위치하는지 확인합니다.
|
||
/// 화면 전체 크기인 요소(컨테이너)는 제외합니다.
|
||
/// </summary>
|
||
/// <param name="screenPosition">
|
||
/// Unity 스크린 좌표 (예: <c>Input.mousePosition</c>).
|
||
/// 좌하단이 원점이며, UI Toolkit 패널 좌표로 자동 변환됩니다.
|
||
/// </param>
|
||
/// <param name="mouseDown">마우스 버튼이 눌려 있는지 여부</param>
|
||
/// <returns>포인터가 등록된 VisualElement의 실제 자식 UI 위에 있으면 true, 아니면 false</returns>
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// [설계 의도] mouseDown 시점의 결과를 캐싱하는 이유
|
||
//
|
||
// UI Toolkit의 Dropdown은 선택지를 팝업 패널로 표시합니다.
|
||
// 사용자가 팝업 항목을 클릭하면, MouseUp 이벤트가 발생하는 시점에는
|
||
// 팝업이 이미 닫혀 패널에서 사라진 상태이므로 Pick() 결과가 null이 됩니다.
|
||
// 즉, MouseUp만 체크하면 "UI 위에서 클릭했음에도 false"가 반환되어
|
||
// 3D 오브젝트 선택 등 의도치 않은 동작이 발생합니다.
|
||
//
|
||
// 이를 해결하기 위해:
|
||
// 1. MouseDown(mouseDown=true) 시점에 UI 판정 결과를 저장
|
||
// 2. MouseUp(mouseDown=false) 시점에 시간/거리 조건으로 같은 위치 클릭인지 판단
|
||
// 3. 클릭으로 판단되면 MouseDown 시점의 결과를 반환
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
public static bool IsPointerOverUI(Vector2 screenPosition, bool mouseDown = false)
|
||
{
|
||
if (mouseDown)
|
||
{
|
||
// mouseDown=true: 현재 판정 결과를 저장
|
||
bool result = PickOverUI(screenPosition);
|
||
_mouseDownTime = Time.realtimeSinceStartup;
|
||
_mouseDownPosition = screenPosition;
|
||
_mouseDownResult = result;
|
||
_hasMouseDownState = true;
|
||
return result;
|
||
}
|
||
else
|
||
{
|
||
// mouseDown=false: 같은 위치 클릭 여부 판단
|
||
if (_hasMouseDownState)
|
||
{
|
||
float elapsed = Time.realtimeSinceStartup - _mouseDownTime;
|
||
float distance = Vector2.Distance(screenPosition, _mouseDownPosition);
|
||
|
||
if (elapsed <= ClickMaxDuration && distance <= ClickMaxDistance)
|
||
{
|
||
// 클릭으로 판단 → mouseDown 시점 결과 반환
|
||
return _mouseDownResult;
|
||
}
|
||
}
|
||
|
||
return PickOverUI(screenPosition);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 주어진 스크린 좌표에서 UI Toolkit 요소 위에 포인터가 있는지 실제로 판정합니다.
|
||
/// </summary>
|
||
private static bool PickOverUI(Vector2 screenPosition)
|
||
{
|
||
List<VisualElement> elementsToCheck = new();
|
||
|
||
foreach (var element in _registeredElements)
|
||
{
|
||
foreach (var e in element.panel.visualTree.Children())
|
||
{
|
||
if (e.pickingMode == PickingMode.Ignore)
|
||
{
|
||
elementsToCheck.AddRange(e.Children());
|
||
}
|
||
else
|
||
{
|
||
elementsToCheck.Add(e);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
foreach (var element in elementsToCheck)
|
||
{
|
||
if (element.panel == null) continue;
|
||
|
||
// Unity 스크린 좌표(좌하단 원점) → UI Toolkit 패널 좌표(좌상단 원점)로 Y축 반전
|
||
var flippedPosition = new Vector2(screenPosition.x, Screen.height - screenPosition.y);
|
||
var panelPosition = RuntimePanelUtils.ScreenToPanel(element.panel, flippedPosition);
|
||
|
||
// 해당 위치의 VisualElement 검사
|
||
var picked = element.panel.Pick(panelPosition);
|
||
|
||
if (picked == null) continue;
|
||
|
||
// PickingMode.Ignore인 요소는 UI 블로킹 대상에서 제외
|
||
if (picked.pickingMode == PickingMode.Ignore) continue;
|
||
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// target이 ancestor의 자손(descendant)인지 확인합니다.
|
||
/// </summary>
|
||
private static bool IsDescendantOf(VisualElement target, VisualElement ancestor)
|
||
{
|
||
var current = target.parent;
|
||
while (current != null)
|
||
{
|
||
if (current == ancestor) return true;
|
||
current = current.parent;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 요소의 크기가 화면 전체(Screen.width × Screen.height)와 일치하는지 확인합니다.
|
||
/// </summary>
|
||
private static bool IsFullScreenElement(VisualElement element)
|
||
{
|
||
var rect = element.worldBound;
|
||
return rect.width >= Screen.width && rect.height >= Screen.height;
|
||
}
|
||
}
|
||
}
|