슬롯 툴팁과 UI 위치 보정

목차

1. 요구 사항

2. 설계 목표

3. 흐름도

4. 구현

        4.1. 슬롯에 포인트가 들어오고 나갔을 때

       4.2. 툴팁 활성화 비활성

       4.3. 인벤토리 툴팁 초기화

       4.4. 툴팁 UI 설정

       4.5. 툴팁 위치 계산

       4.6. raycastTarget 비활성화 처리

5. 개발 의도

1. 시스템 요구 사항

인벤토리 시스템에서 툴팁은 단순한 정보 패널이 아니다.

슬롯 위에 포인터가 올라갔을 때 자연스럽게 등장해야 하고, 슬롯을 벗어나거나 드래그가 시작되면 즉시 사라져야 한다.

하지만 구현 단계에서 가장 먼저 마주한 문제는 슬롯 기준 위치와 화면 기준 좌표가 일치하지 않는다는 점이었다.

Canvas가 Screen Space - Overlay인지, Camera 모드인지에 따라 좌표 체계가 달라지고, CanvasScaler 설정에 따라 해상도별 UI 스케일이 변한다.

단순히 슬롯 transform.position에 툴팁을 붙이는 방식으로는 해상도에 따라 툴팁이 화면 밖으로 밀려나거나, 상하 반전되어 보이거나, 가려지는 문제가 발생했다.

따라서 툴팁은 슬롯 기준 표시가 아니라 화면 기준 보정된 표시가 필요했다.

2. 설계  목표

- 툴팁은 슬롯 진입 시에만 표시할 것

- 툴팁 위치는 슬롯을 기준으로 계산하되, 화면 경계를 넘어가지 않도록 보정할 것

- CanvasScaler 영향을 고려한 좌표 보정 로직을 포함할 것

- 툴팁은 데이터 읽기 전용으로 유지할 것

3. 흐름도

이 흐름의 핵심은, 툴팁의 데이터 세팅과 위치 보정을 분리했다는 점이다.

데이터는 슬롯이 결정하고, 위치는 화면 경계와 Canvas 설정을 기준으로 재계산한다.

이 구조 덕분에 툴팁은 어떤 해상도에서도 일관된 동작을 보장한다.

4. 구현

4.1. 슬롯에 포인트가 들어오고 나갔을 때
// InventorySlot.cs
public void OnPointerEnter(PointerEventData eventData)
{
    highLight.SetActive(true);
    ShowTooltip();
}


public void OnPointerExit(PointerEventData eventData)
{
    highLight.SetActive(false);
    CloseTooltip();
}

인벤토리 슬롯에서 툴팁은 OnPointerEnter와 OnPointerExit 이벤트를 통해 호출된다.

이는 Unity의 IPointerEnterHandler, IPointerExitHandler 인터페이스 기반 구조이며, EventSystem이 Raycast 결과에 따라 해당 UI 오브젝트에 이벤트를 전달하는 방식이다.

여기서 중요한 점은, 슬롯이 툴팁을 직접 구성하지 않는다는 것이다.

OnPointerEnter은 포인터가 슬롯에 진입했을 때 호출된다.

포인터가 슬롯에 들어오면 하이라이트를 활성화하고, ShowTooltip을 활성화하여, 툴팁을 활성화한다.

OnPointerExit는 포인터가 슬롯을 벗어났을 때 호출된다.

포인터가 슬롯을 벗어나면 하이라이트를 비활성화하고, 그 즉시 툴팁을 비활성화한다.

툴팁은 고정 UI가 아니라 상황 기반 UI이기 때문에 항상 일시적으로 존재해야 한다.

4.2. 툴팁 활성화 비활성
// InventorySlot.cs
public void ShowTooltip()
{
   if (slotData == null || slotData.IsEmpty || slotData.data == null) return;
   if (tooltipPanel.activeSelf) return;
   
   RectTransform rectTransform = GetComponent<RectTransform>();
   
   tooltipPanel.SetActive(true);
   tooltipPanel.GetComponent<InventoryTooltip>().SetupTooltipInven(slotData.data, rectTransform);
}

public void CloseTooltip()
{
    if (tooltipPanel.activeSelf)
    tooltipPanel.SetActive(false);
}

ShowTooltip은 툴팁 표시의 시작점이다.

먼지 slotData가 null이거나 비어있거나, 또는 slotData의 data가 비있으면 즉시 return 한다.

이 검사는 단순 null 체크가 아니라, UI 이벤트가 데이터 상태보다 늦게 도착할 수 있는 상황을 방어하기 위한 것이다.

예를 들어, 드래그 도중 슬롯이 비워졌거나, 아이템이 소비되었거나, PoplateSlots가 실행된 직후라면, UI는 아직 포인터 상태를 유지하고 있지만, 데이터는 이미 변경되었을 수 있다.

UI는 데이터에 의존해야 하므로, 이벤트 진입 시점에 반드시 데이터 상태를 다시 확정한다.

이후, 인벤토리 슬롯의 RectTransform을 GetComponent<>로 가져오고, 툴팁을 활성화한다.

가져온 위치를 SetupTooltipInven()에서 SetRectPosition() 함수에 매개변수로 넘겨, 툴팁 위치를 보정한다.

툴팁은 슬롯 위치 그대로가 아니라, 슬롯 RectTransform을 기준으로 화면 좌표 변환을 거쳐 위치를 계산한다.

4.3. 인벤토리 툴팁 초기화
private RectTransform rt;
private CanvasScaler canvasScaler;
private static readonly Vector2 LeftTop = new Vector2(0f, 1f);

private void Awake()
{    
    Initialized();
}

private void Initialized()
{
    TryGetComponent(out rt);
    rt.pivot = LeftTop;
    
    canvasScaler = GetComponentInParent<CanvasScaler>();
    if (canvasScaler == null)
    {
        canvasScaler = FindObjectOfType<CanvasScaler>();
    }

    DisableAllChildrenRaycastTarget(transform);
}

여기서 rt.pivot = (0, 1)로 좌상단 기준으로 고정한 이유는 위치 계산을 단순화하기 위함이다.

pivot이 중앙이면 위치 계산 시 절반 크기 보정이 필요하다.

좌상단 기준으로 고정하면, 툴팁의 좌상단 좌표를 직접 계산하는 방식으로 논리를 단순하게 유지할 수 있다.

이는 수학적 안정성을 높이는 선택이다.

CanvasScaler를 캐싱하는 이유도 중요하다.

Unity UI는 referenceResolution을 기준으로 스케일링되기 때문에, rect.width는 논리적 크기이며 실제 픽셀 크기와 다를 수 있다.

따라서 해상도 독립적 위치 계산을 위해 반드시 비율 보정이 필요하다.

4.4. 툴팁 UI 설정
// InventoryTooltip.cs
public void SetupTooltipInven(InventoryItemData data, RectTransform slotRect)
{
    if (data == null) return;
    
    nameText.text = data.itemName;
    itemDescription.text = data.Tooltip;
    SetRectPosition(slotRect);
}

SetupTooltipInven은 데이터 세팅을 담당한다.

텍스트 UI에 아이템 이름, 설명을 반영한다.

이후, SetRectPosition함수를 호출하여 위치를 보정한다.

이때, ShowTooltip함수에서 넘겨받은 슬롯의 위치를 전달한다.

4.5. 툴팁 위치 계산
// InventoryTooltip.cs
public void SetRectPosition(RectTransform slotRect)
{
    if (canvasScaler == null)
    {
        rt.position = slotRect.position;
        return;
    }
    float wRatio = Screen.width / canvasScaler.referenceResolution.x;
    float hRatio = Screen.height / canvasScaler.referenceResolution.y;
    float ratio =
        wRatio * (1f - canvasScaler.matchWidthOrHeight) +
        hRatio * (canvasScaler.matchWidthOrHeight);
    
    float slotWidth = slotRect.rect.width * ratio;
    float slotHeight = slotRect.rect.height * ratio;
  
    rt.position = slotRect.position + new Vector3(slotWidth, -slotHeight);
    Vector2 pos = rt.position;
   
    float width = rt.rect.width * ratio;
    float height = rt.rect.height * ratio;
   
    bool rightTruncated = pos.x + width > Screen.width;
    bool bottomTruncated = pos.y - height < 0f;
    
    if (rightTruncated && !bottomTruncated)
    {
	    rt.position = new Vector2(pos.x - width - slotWidth, pos.y);
    }
    else if (!rightTruncated && bottomTruncated)
    {
        rt.position = new Vector2(pos.x, pos.y + height + slotHeight);
    }
    else if (rightTruncated && bottomTruncated)
    {
        rt.position = new Vector2(pos.x - width - slotWidth, pos.y + height + slotHeight);
    }
}

이 계산은 CanvasScaler 내부 로직과 동일한 방식이다.

matchWidthOrHeight 값에 따라 가로/세로 비율을 가중 평균하여 실제 스케일 비율을 계산한다.

이 과정을 거치지 않으면 특정 해상도에서 툴팁 위치가 어긋난다. 특히 모바일 디바이스에서 해상도 차이가 클 경우 오차가 눈에 띄게 발생한다.

기본 위치는 슬롯의 오른쪽 아래로 설정된다.

rt.position = slotRect.position + new Vector3(slotWidth, -slotHeight);

pivot이 좌상단이므로, 이 계산은 툴팁의 좌상단을 슬롯의 우하단에 맞춘다는 의미다.

그러나 화면 경계를 벗어날 가능성이 있다. 이를 방지하기 위해 다음 조건 검사를 수행한다.

bool rightTruncated = pos.x + width > Screen.width;

bool bottomTruncated = pos.y - height < 0f;

오른쪽 화면을 벗어나면 좌측으로 반전하고, 아래쪽을 벗어나면 위쪽으로 반전한다.

두 조건이 모두 참이면 좌상단 방향으로 이동한다.

이 알고리즘은 사분면 반전 방식으로, 추가적인 분기 없이 화면 경계 대응을 처리한다.

이 구조 덕분에 해상도나 슬롯 위치에 관계없이 항상 화면 안쪽에 툴팁이 표시된다.

또 하나 중요한 구현은 raycastTarget 비활성화 처리다.

툴팁 내부 Graphic 컴포넌트의 raycastTarget을 false로 설정해 입력을 가로채지 않도록 한다.

이 설정이 없으면 마우스가 툴팁 위에 올라가는 순간 슬롯의 OnPointerExit가 호출되지 않을 수 있다.

그 결과 툴팁이 닫히지 않는 버그가 발생할 수 있다.

툴팁은 표시 전용 UI이기 때문에 입력을 받을 필요가 없으며, raycastTarget을 끄는 것이 올바른 설계다.

이 툴팁 시스템은 단순한 정보 표시가 아니다.

데이터 계층과 UI 계층의 역할을 엄격히 분리하면서, 해상도 독립적 계산과 화면 경계 보정까지 포함한 구조다.

UI는 데이터를 판단하지 않고 표현만 한다는 인벤토리 시스템의 핵심 철학을 그대로 유지한다.

또한 CanvasScaler에 대응함으로써 어떤 해상도에서도 안정적인 위치를 보장한다.

이 설계는 이후 확장에도 안전하다.

예를 들어 장비 비교 툴팁, 마우스 추적형 툴팁, 월드 공간 UI 대응 등으로 확장하더라도 현재 구조를 유지한 채 로직을 추가할 수 있다.

데이터가 진실이고 UI는 반영이라는 방향성을 유지하는 한, 이 구조는 흔들리지 않는다.

4.6. raycastTarget 비활성화 처리
private void DisableAllChildrenRaycastTarget(Transform tr)
{
    tr.TryGetComponent(out Graphic gr);
    if (gr != null) gr.raycastTarget = false;
    
    int childCount = tr.childCount;
    if (childCount == 0) return;
    
    for (int i = 0; i < childCount; i++)
    {
        DisableAllChildrenRaycastTarget(tr.GetChild(i));
    }
}

이 함수는 Graphic 컴포넌트의 raycastTarget을 전부 false로 만든다.

이 처리가 중요한 이유는, 툴팁이 마우스를 가로채면 슬롯의 OnPointerExit가 호출되지 않는다.

그 결과, 툴팁이 닫히지 않거나 하이라이트가 남는 현상이 발생할 수 있다.

툴팁은 입력을 받지 않는 표시 전용 UI이기 때문에, raycastTarget을 끄는 것이 맞는 설계다.

5. 개발 의도

이 게시글에서 보여주고 싶었던 핵심은 툴팁을 단순 정보 UI로 끝내지 않고, 해상도와 Canvas 스케일까지 고려한 구조로 설계했다는 점이다.

슬롯 기준 좌표만 사용했다면 구현은 쉬웠겠지만 해상도 대응에서 반드시 문제가 발생했을 것이다.

UI는 눈에 보이는 부분이지만, 실제로는 좌표계와 스케일 계산이 핵심이다.

이 구조는 이후 비교 툴팁, 장비 강화 미리보기, 상태 이상 설명 팝업 등으로 확장하더라도 기존 위치 보정 로직을 그대로 재사용할 수 있도록 설계되었다.