Files
EnglewoodLAB/Assets/Scripts/UVC/UIToolkit/Util/UTKPointerBlocker.cs

204 lines
8.4 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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;
}
}
}