Files
BeatSaber/Assets/Script/VRPointerController.cs
T
2026-05-28 19:01:20 +09:00

513 lines
20 KiB
C#

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using UnityEngine.XR;
namespace VRBeats
{
[RequireComponent(typeof(LineRenderer))]
public class VRPointerController : MonoBehaviour
{
[SerializeField] private bool isRightHand = true;
[SerializeField] private float maxDistance = 50f;
[SerializeField] private bool debugLogging = false;
[SerializeField] private float scrollSpeed = 2.4f;
[SerializeField] private float scrollDeadZone = 0.15f;
[SerializeField] private float dragScrollSpeed = 1.25f;
[SerializeField] private float dragClickThreshold = 0.025f;
private LineRenderer _line;
private bool _prevTrigger;
private Selectable _currentHover;
private ScrollRect _dragScrollRect;
private Selectable _triggerPressSelectable;
private Vector2 _dragStartLocalPoint;
private float _dragStartNormalizedPosition;
private float _dragMaxNormalizedDelta;
private static readonly Color NormalColor = new Color(1f, 1f, 1f, 0.8f);
private static readonly Color HoverColor = new Color(0.3f, 0.8f, 1f, 1f);
private float _deviceLogTimer;
// 버튼별 이전 상태
private bool _prevGrip;
private bool _prevPrimary;
private bool _prevSecondary;
private bool _prevThumbstick;
private void Awake()
{
_line = GetComponent<LineRenderer>();
_line.positionCount = 2;
_line.startWidth = 0.005f;
_line.endWidth = 0.001f;
_line.useWorldSpace = true;
// enabled 상태는 OnEnable/OnDisable이 관리 — Awake에서 건드리지 않음
}
private void Start()
{
// Awake 이후 reflection으로 isRightHand가 설정되므로 Start에서 로그
if (debugLogging)
Debug.Log($"[VRPointer] Start — {gameObject.name} / isRightHand={isRightHand}");
}
private void OnEnable()
{
if (_line != null) _line.enabled = true;
}
private void OnDisable()
{
if (_line != null) _line.enabled = false;
}
private void Update()
{
// 3초마다 연결된 디바이스 목록 출력
if (debugLogging)
{
_deviceLogTimer += Time.deltaTime;
if (_deviceLogTimer >= 3f)
{
_deviceLogTimer = 0f;
LogConnectedDevices();
}
}
bool trigger = GetButton(CommonUsages.triggerButton);
bool grip = GetButton(CommonUsages.gripButton);
bool primary = GetButton(CommonUsages.primaryButton);
bool secondary = GetButton(CommonUsages.secondaryButton);
bool thumbstick = GetButton(CommonUsages.primary2DAxisClick);
bool triggerDown = trigger && !_prevTrigger;
bool triggerUp = !trigger && _prevTrigger;
bool gripDown = grip && !_prevGrip;
bool primaryDown = primary && !_prevPrimary;
bool secondaryDown = secondary && !_prevSecondary;
bool thumbstickDown = thumbstick && !_prevThumbstick;
string hand = isRightHand ? "R" : "L";
if (debugLogging)
{
if (triggerDown) Debug.Log($"[VRPointer:{hand}] 검지 트리거 눌림");
if (gripDown) Debug.Log($"[VRPointer:{hand}] 그립(중지) 눌림");
if (primaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "A" : "X")} 버튼 눌림");
if (secondaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "B" : "Y")} 버튼 눌림");
if (thumbstickDown) Debug.Log($"[VRPointer:{hand}] 조이스틱 클릭 눌림");
}
Ray ray = new Ray(transform.position, transform.forward);
float selectableHitDist = maxDistance;
float scrollHitDist = maxDistance;
Selectable hit = FindSelectableUnderRay(ray, ref selectableHitDist);
ScrollRect scrollRect = FindScrollRectUnderRay(ray, ref scrollHitDist);
float hitDist = Mathf.Min(selectableHitDist, scrollHitDist);
bool beganScrollDrag = false;
if (triggerDown)
beganScrollDrag = TryBeginScrollDrag(scrollRect, hit, ray);
if (_dragScrollRect != null && trigger)
UpdateScrollDrag(ray);
else if (_dragScrollRect == null)
HandleScroll(scrollRect);
// 호버 변화 로그
if (debugLogging && hit != _currentHover)
{
Debug.Log(hit != null
? $"[VRPointer] HOVER → {hit.gameObject.name}"
: $"[VRPointer] HOVER → (없음)");
}
UpdateHoverState(hit);
if (triggerUp && _dragScrollRect != null)
EndScrollDrag(hand, ray);
// 검지 트리거 또는 A/X 버튼으로 클릭.
// ScrollRect 위의 검지 트리거는 드래그/클릭 판별을 위해 release 시점에 처리한다.
if ((triggerDown && !beganScrollDrag) || primaryDown)
{
if (_currentHover != null)
{
string btn = triggerDown && !beganScrollDrag ? "검지 트리거" : (isRightHand ? "A" : "X");
if (debugLogging)
Debug.Log($"[VRPointer:{hand}] CLICK [{btn}] → {_currentHover.gameObject.name}");
Click(_currentHover);
}
else if (debugLogging)
{
Debug.Log($"[VRPointer:{hand}] CLICK — 레이 아래 버튼 없음 " +
$"pos={transform.position:F2} fwd={transform.forward:F2}");
DebugRaycastAttempt(new Ray(transform.position, transform.forward));
}
}
DrawLine(hitDist);
_prevTrigger = trigger;
_prevGrip = grip;
_prevPrimary = primary;
_prevSecondary = secondary;
_prevThumbstick = thumbstick;
}
private void LogConnectedDevices()
{
var all = new List<InputDevice>();
InputDevices.GetDevices(all);
if (all.Count == 0)
{
Debug.LogWarning($"[VRPointer] 연결된 XR 디바이스 없음 ({gameObject.name})");
return;
}
var chars = InputDeviceCharacteristics.Controller |
(isRightHand ? InputDeviceCharacteristics.Right : InputDeviceCharacteristics.Left);
var matched = new List<InputDevice>();
InputDevices.GetDevicesWithCharacteristics(chars, matched);
Debug.Log($"[VRPointer] 전체 디바이스 {all.Count}개 | " +
$"{(isRightHand ? "" : "")} 컨트롤러 {matched.Count}개 ({gameObject.name})");
}
private void UpdateHoverState(Selectable hit)
{
if (hit == _currentHover) return;
var es = EventSystem.current;
if (_currentHover != null)
ExecuteEvents.Execute(_currentHover.gameObject,
new PointerEventData(es), ExecuteEvents.pointerExitHandler);
_currentHover = hit;
if (_currentHover != null)
ExecuteEvents.Execute(_currentHover.gameObject,
new PointerEventData(es), ExecuteEvents.pointerEnterHandler);
}
private static void Click(Selectable sel)
{
var es = EventSystem.current;
if (es == null)
{
Debug.LogWarning("[VRPointer] EventSystem.current is null — click 실패");
return;
}
var eventData = new PointerEventData(es);
ExecuteEvents.Execute(sel.gameObject, eventData, ExecuteEvents.pointerDownHandler);
ExecuteEvents.Execute(sel.gameObject, eventData, ExecuteEvents.pointerUpHandler);
ExecuteEvents.Execute(sel.gameObject, eventData, ExecuteEvents.pointerClickHandler);
var btn = sel.GetComponent<Button>();
if (btn != null) btn.onClick.Invoke();
}
private void DrawLine(float hitDist)
{
Color c = _currentHover != null ? HoverColor : NormalColor;
_line.startColor = c;
_line.endColor = new Color(c.r, c.g, c.b, 0f);
_line.SetPosition(0, transform.position);
_line.SetPosition(1, transform.position + transform.forward * hitDist);
}
private static void DebugRaycastAttempt(Ray ray)
{
var all = Selectable.allSelectablesArray;
Debug.Log($"[VRPointer:DEBUG] Selectable 총 {all.Length}개 | ray.origin={ray.origin:F2} ray.dir={ray.direction:F2}");
foreach (Selectable sel in all)
{
if (!sel.gameObject.activeInHierarchy) { Debug.Log($" SKIP(비활성) {sel.gameObject.name}"); continue; }
if (!sel.interactable) { Debug.Log($" SKIP(interactable=false) {sel.gameObject.name}"); continue; }
var rt = sel.GetComponent<RectTransform>();
if (rt == null) { Debug.Log($" SKIP(RectTransform없음) {sel.gameObject.name}"); continue; }
Vector3[] c = new Vector3[4];
rt.GetWorldCorners(c);
Vector3 normal = rt.forward;
if (Vector3.Dot(normal, ray.direction) >= 0f) normal = -normal;
Plane plane = new Plane(normal, c[0]);
bool hit = plane.Raycast(ray, out float dist);
if (!hit) { Debug.Log($" MISS(Plane.Raycast=false) {sel.gameObject.name} | rtPos={rt.position:F2} normal={normal:F2}"); continue; }
bool inRect = IsPointInRect(ray.GetPoint(dist), c);
Debug.Log($" {(inRect ? "HIT" : "MISS(InRect=false)")} {sel.gameObject.name} | dist={dist:F2} inRect={inRect}");
}
}
private static Selectable FindSelectableUnderRay(Ray ray, ref float maxDist)
{
Selectable closest = null;
float closestDist = maxDist;
foreach (Selectable sel in Selectable.allSelectablesArray)
{
if (!sel.gameObject.activeInHierarchy || !sel.interactable) continue;
if (!IsOnEnabledCanvas(sel)) continue;
var rt = sel.GetComponent<RectTransform>();
if (rt == null) continue;
Vector3[] corners = new Vector3[4];
rt.GetWorldCorners(corners);
// Plane.Raycast은 normal 쪽(앞면)에서 레이가 출발해야 true 반환.
// rt.forward가 카메라 반대를 향할 수 있으므로 레이 원점 방향으로 노말 보정.
Vector3 normal = rt.forward;
if (Vector3.Dot(normal, ray.direction) >= 0f)
normal = -normal;
Plane plane = new Plane(normal, corners[0]);
if (!plane.Raycast(ray, out float dist)) continue;
if (dist >= closestDist || dist <= 0f) continue;
if (!IsPointInRect(ray.GetPoint(dist), corners)) continue;
closestDist = dist;
closest = sel;
}
maxDist = closestDist;
return closest;
}
private static bool IsPointInRect(Vector3 p, Vector3[] c)
{
Vector3 toP = p - c[0];
Vector3 right = c[3] - c[0];
Vector3 up = c[1] - c[0];
float r = Vector3.Dot(toP, right) / right.sqrMagnitude;
float u = Vector3.Dot(toP, up) / up.sqrMagnitude;
return r >= 0f && r <= 1f && u >= 0f && u <= 1f;
}
private static ScrollRect FindScrollRectUnderRay(Ray ray, ref float maxDist)
{
ScrollRect closest = null;
float closestDist = maxDist;
var all = Object.FindObjectsByType<ScrollRect>(FindObjectsSortMode.None);
foreach (ScrollRect scroll in all)
{
if (!scroll.isActiveAndEnabled) continue;
if (!IsOnEnabledCanvas(scroll)) continue;
RectTransform rt = scroll.viewport != null
? scroll.viewport
: scroll.GetComponent<RectTransform>();
if (rt == null) continue;
Vector3[] corners = new Vector3[4];
rt.GetWorldCorners(corners);
Vector3 normal = rt.forward;
if (Vector3.Dot(normal, ray.direction) >= 0f)
normal = -normal;
Plane plane = new Plane(normal, corners[0]);
if (!plane.Raycast(ray, out float dist)) continue;
if (dist >= closestDist || dist <= 0f) continue;
if (!IsPointInRect(ray.GetPoint(dist), corners)) continue;
closestDist = dist;
closest = scroll;
}
if (closest != null)
maxDist = closestDist;
return closest;
}
private static bool IsOnEnabledCanvas(Component component)
{
Canvas[] canvases = component.GetComponentsInParent<Canvas>(true);
if (canvases.Length == 0)
return true;
for (int i = 0; i < canvases.Length; i++)
{
Canvas canvas = canvases[i];
if (canvas == null)
continue;
if (!canvas.enabled || !canvas.gameObject.activeInHierarchy)
return false;
}
return true;
}
private void HandleScroll(ScrollRect scrollRect)
{
if (!CanScrollVertically(scrollRect))
return;
Vector2 axis = GetAxis(CommonUsages.primary2DAxis);
if (Mathf.Abs(axis.y) < scrollDeadZone)
return;
scrollRect.verticalNormalizedPosition = Mathf.Clamp01(
scrollRect.verticalNormalizedPosition + axis.y * scrollSpeed * Time.deltaTime);
}
private bool TryBeginScrollDrag(ScrollRect scrollRect, Selectable pressSelectable, Ray ray)
{
if (!CanScrollVertically(scrollRect))
return false;
if (!TryGetScrollLocalPoint(scrollRect, ray, out Vector2 localPoint, out _))
return false;
_dragScrollRect = scrollRect;
_triggerPressSelectable = pressSelectable;
_dragStartLocalPoint = localPoint;
_dragStartNormalizedPosition = scrollRect.verticalNormalizedPosition;
_dragMaxNormalizedDelta = 0f;
return true;
}
private void UpdateScrollDrag(Ray ray)
{
if (_dragScrollRect == null)
return;
if (!TryGetScrollLocalPoint(_dragScrollRect, ray, out Vector2 localPoint, out float viewportHeight))
return;
float deltaY = localPoint.y - _dragStartLocalPoint.y;
float normalizedDelta = deltaY / viewportHeight * dragScrollSpeed;
_dragMaxNormalizedDelta = Mathf.Max(_dragMaxNormalizedDelta, Mathf.Abs(normalizedDelta));
_dragScrollRect.verticalNormalizedPosition = Mathf.Clamp01(
_dragStartNormalizedPosition - normalizedDelta);
}
private void EndScrollDrag(string hand, Ray ray)
{
bool shouldClick = _dragMaxNormalizedDelta < dragClickThreshold;
ScrollRect scrollRect = _dragScrollRect;
Selectable pressSelectable = _triggerPressSelectable;
float startNormalizedPosition = _dragStartNormalizedPosition;
ClearScrollDrag();
if (!shouldClick)
return;
if (scrollRect != null)
scrollRect.verticalNormalizedPosition = startNormalizedPosition;
if (pressSelectable != null && pressSelectable.isActiveAndEnabled && pressSelectable.interactable)
{
if (debugLogging)
Debug.Log($"[VRPointer:{hand}] CLICK [검지 트리거] → {pressSelectable.gameObject.name}");
Click(pressSelectable);
}
else if (debugLogging)
{
Debug.Log($"[VRPointer:{hand}] CLICK — 레이 아래 버튼 없음 " +
$"pos={transform.position:F2} fwd={transform.forward:F2}");
DebugRaycastAttempt(ray);
}
}
private void ClearScrollDrag()
{
_dragScrollRect = null;
_triggerPressSelectable = null;
_dragStartLocalPoint = Vector2.zero;
_dragStartNormalizedPosition = 0f;
_dragMaxNormalizedDelta = 0f;
}
private static bool CanScrollVertically(ScrollRect scrollRect)
{
if (scrollRect == null || !scrollRect.vertical)
return false;
RectTransform viewport = scrollRect.viewport != null
? scrollRect.viewport
: scrollRect.GetComponent<RectTransform>();
if (viewport == null || scrollRect.content == null)
return true;
return scrollRect.content.rect.height > viewport.rect.height + 1f;
}
private static bool TryGetScrollLocalPoint(ScrollRect scrollRect, Ray ray, out Vector2 localPoint, out float viewportHeight)
{
localPoint = Vector2.zero;
viewportHeight = 1f;
RectTransform rt = scrollRect.viewport != null
? scrollRect.viewport
: scrollRect.GetComponent<RectTransform>();
if (rt == null)
return false;
Vector3[] corners = new Vector3[4];
rt.GetWorldCorners(corners);
Vector3 normal = rt.forward;
if (Vector3.Dot(normal, ray.direction) >= 0f)
normal = -normal;
Plane plane = new Plane(normal, corners[0]);
if (!plane.Raycast(ray, out float dist) || dist <= 0f)
return false;
Vector3 local = rt.InverseTransformPoint(ray.GetPoint(dist));
localPoint = new Vector2(local.x, local.y);
viewportHeight = Mathf.Max(1f, rt.rect.height);
return true;
}
private bool GetButton(InputFeatureUsage<bool> usage)
{
var chars = InputDeviceCharacteristics.Controller |
(isRightHand
? InputDeviceCharacteristics.Right
: InputDeviceCharacteristics.Left);
var devices = new List<InputDevice>();
InputDevices.GetDevicesWithCharacteristics(chars, devices);
if (devices.Count == 0) return false;
devices[0].TryGetFeatureValue(usage, out bool pressed);
return pressed;
}
private Vector2 GetAxis(InputFeatureUsage<Vector2> usage)
{
var chars = InputDeviceCharacteristics.Controller |
(isRightHand
? InputDeviceCharacteristics.Right
: InputDeviceCharacteristics.Left);
var devices = new List<InputDevice>();
InputDevices.GetDevicesWithCharacteristics(chars, devices);
if (devices.Count == 0) return Vector2.zero;
devices[0].TryGetFeatureValue(usage, out Vector2 axis);
return axis;
}
}
}