Unity UI Toolkit 반응형 툴팁과 인벤토리 슬롯 시스템 구현

Unity UI Toolkit 반응형 툴팁과 인벤토리 슬롯 시스템 구현

Unity UI Toolkit으로 화면 경계에 맞춰 위치를 보정하는 반응형 툴팁과 재사용 가능한 인벤토리 슬롯을 구현하는 실전 설계를 정리합니다.

UI Toolkit에서 툴팁이 잘리는 문제는 툴팁을 개별 슬롯의 자식으로 두기 때문에 발생한다. 툴팁은 UIDocument.rootVisualElement의 최상단 오버레이로 한 번만 만들고 PointerEnterEventPointerMoveEvent의 패널 좌표를 기준으로 배치하면 해결된다.

인벤토리 슬롯은 UXML 템플릿 하나를 반복 인스턴스화하고 아이템 데이터만 주입하는 방식이 가장 단순하다. 화면 경계 보정은 툴팁의 크기와 루트 영역을 비교해 left, top 값을 제한한다.

Unity UI Toolkit 툴팁이 부모 밖에서 잘리는 이유는 무엇일까?

슬롯의 VisualElement 아래에 툴팁을 추가하면 슬롯 또는 상위 컨테이너의 overflow: hidden 영향을 받는다. 또한 슬롯마다 툴팁을 생성하면 이벤트 등록과 UI 요소 수가 불필요하게 늘어난다.

해결 원칙은 다음과 같다.

문제단순한 해결 방법
슬롯 경계에서 툴팁이 잘림툴팁을 루트 오버레이에 추가
화면 오른쪽·아래에서 툴팁이 잘림루트 worldBound로 좌표 제한
슬롯마다 툴팁 생성툴팁 인스턴스 1개 재사용
마우스 이동 중 정보 갱신PointerMoveEvent에서 위치만 갱신

PointerEventBase.positionVisualElement.worldBound는 패널 좌표계 기준이므로 같은 좌표계에서 비교할 수 있다.

인벤토리 슬롯 UI를 어떻게 구성할까?

먼저 슬롯 모양은 UXML 템플릿으로 고정하고 아이콘·수량·아이템 데이터만 C#에서 설정한다. 드래그 앤 드롭, 장비 비교, 필터링은 실제 요구가 생길 때 추가하는 편이 낫다.

1. 슬롯 UXML 템플릿 만들기

InventorySlot.uxml:

<ui:UXML xmlns:ui="UnityEngine.UIElements">
  <ui:VisualElement name="slot" class="inventory-slot">
    <ui:VisualElement name="icon" class="slot-icon" />
    <ui:Label name="amount" class="slot-amount" />
  </ui:VisualElement>
</ui:UXML>

Inventory.uss:

.inventory-grid {
  flex-direction: row;
  flex-wrap: wrap;
  gap: 8px;
}

.inventory-slot {
  width: 72px;
  height: 72px;
  position: relative;
  background-color: rgb(35, 39, 48);
  border-radius: 6px;
}

.slot-icon {
  flex-grow: 1;
  margin: 6px;
  -unity-background-scale-mode: scale-to-fit;
}

.slot-amount {
  position: absolute;
  right: 5px;
  bottom: 3px;
  color: white;
  -unity-font-style: bold;
}

UI Toolkit 인벤토리 그리드에서 아이콘과 수량을 표시하는 슬롯 구성

2. 아이템 데이터와 슬롯 생성 코드 작성하기

ScriptableObject나 저장 데이터에서 얻은 아이템 정보를 아래처럼 전달한다. 여기서는 필요한 표시 데이터만 둔다.

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;

public readonly record struct InventoryItem(
    string Name,
    string Description,
    Texture2D Icon,
    int Amount);

public sealed class InventoryView : MonoBehaviour
{
    [SerializeField] private UIDocument document;
    [SerializeField] private VisualTreeAsset slotTemplate;
    [SerializeField] private List<InventoryItemData> items;

    private TooltipController tooltip;

    private void OnEnable()
    {
        var root = document.rootVisualElement;
        tooltip = new TooltipController(root);
        var grid = root.Q<VisualElement>("inventory-grid");

        foreach (var item in items)
        {
            var slot = slotTemplate.Instantiate();
            slot.Q<VisualElement>("icon").style.backgroundImage = item.icon;
            slot.Q<Label>("amount").text = item.amount > 1 ? item.amount.ToString() : "";
            tooltip.Bind(slot, new InventoryItem(item.itemName, item.description, item.icon, item.amount));
            grid.Add(slot);
        }
    }
}

InventoryItemData는 프로젝트에 이미 있는 아이템 정의를 사용하면 된다. 예시의 필드는 itemName, description, icon, amount다.

반응형 툴팁은 어떻게 화면 경계에 맞춰 배치할까?

다음 순서로 구현한다.

  1. rootVisualElement 아래에 툴팁 오버레이를 한 번 추가한다.
  2. 슬롯의 포인터 이벤트에서 이름과 설명을 갱신한다.
  3. 마우스 위치에 오프셋을 더한 뒤 툴팁 크기만큼 화면 경계를 넘지 않도록 제한한다.

3. 재사용 가능한 TooltipController 구현

using UnityEngine;
using UnityEngine.UIElements;

public sealed class TooltipController
{
    private const float Offset = 16f;
    private const float ScreenMargin = 8f;

    private readonly VisualElement root;
    private readonly VisualElement tooltip;
    private readonly Label title;
    private readonly Label description;
    private Vector2 pointerPosition;

    public TooltipController(VisualElement root)
    {
        this.root = root;
        tooltip = new VisualElement { name = "tooltip" };
        title = new Label { name = "tooltip-title" };
        description = new Label { name = "tooltip-description" };

        tooltip.Add(title);
        tooltip.Add(description);
        tooltip.style.display = DisplayStyle.None;
        root.Add(tooltip);
    }

    public void Bind(VisualElement target, InventoryItem item)
    {
        target.RegisterCallback<PointerEnterEvent>(evt => Show(item, evt.position));
        target.RegisterCallback<PointerMoveEvent>(evt => Move(evt.position));
        target.RegisterCallback<PointerLeaveEvent>(_ => Hide());
    }

    private void Show(InventoryItem item, Vector2 position)
    {
        title.text = item.Name;
        description.text = item.Description;
        tooltip.style.display = DisplayStyle.Flex;
        Move(position);
    }

    private void Move(Vector2 position)
    {
        pointerPosition = position;
        tooltip.schedule.Execute(Place);
    }

    private void Place()
    {
        var bounds = root.worldBound;
        var width = tooltip.resolvedStyle.width;
        var height = tooltip.resolvedStyle.height;
        var maxX = bounds.xMax - width - ScreenMargin;
        var maxY = bounds.yMax - height - ScreenMargin;

        tooltip.style.left = Mathf.Clamp(pointerPosition.x + Offset, bounds.xMin + ScreenMargin, maxX);
        tooltip.style.top = Mathf.Clamp(pointerPosition.y + Offset, bounds.yMin + ScreenMargin, maxY);
    }

    private void Hide() => tooltip.style.display = DisplayStyle.None;
}

tooltip.schedule.Execute(Place)는 표시 후 레이아웃 계산이 완료된 시점에 resolvedStyle.widthresolvedStyle.height를 읽기 위한 처리다. 툴팁을 표시하기 전에 크기를 읽으면 0이거나 이전 레이아웃 값일 수 있다.

4. 툴팁 USS 스타일 적용하기

#tooltip {
  position: absolute;
  max-width: 280px;
  padding: 10px 12px;
  background-color: rgba(12, 14, 20, 0.96);
  border-radius: 6px;
  color: white;
  picking-mode: ignore;
}

#tooltip-title {
  margin-bottom: 4px;
  -unity-font-style: bold;
}

#tooltip-description {
  white-space: normal;
  color: rgb(205, 210, 220);
}

picking-mode: ignore는 툴팁이 포인터 입력을 가로채 슬롯의 PointerLeaveEvent를 잘못 발생시키는 일을 막는다.

툴팁 위치 보정에서 확인할 항목

툴팁의 왼쪽 위 좌표를 다음 범위로 제한하면 네 방향 모두에서 잘림을 막을 수 있다.

x=clamp(px+o, rminX+m, rmaxXwm)x = \operatorname{clamp}(p_x + o,\ r_{minX}+m,\ r_{maxX}-w-m) y=clamp(py+o, rminY+m, rmaxYhm)y = \operatorname{clamp}(p_y + o,\ r_{minY}+m,\ r_{maxY}-h-m)
기호의미
p포인터의 패널 좌표
o포인터와 툴팁 사이의 오프셋
r루트 VisualElement의 worldBound
w, h툴팁의 계산된 너비와 높이
m화면 가장자리 여백

화면 크기가 바뀌는 게임에서는 GeometryChangedEvent에서 마지막 포인터 위치로 Place()를 다시 호출하면 된다. 다만 툴팁을 열어 둔 상태에서 해상도 변경을 지원해야 할 때만 추가한다.

자주 묻는 질문 (FAQ)

UI Toolkit 툴팁을 슬롯마다 만들어도 될까?

가능하지만 권장하지 않는다. 목록이 커질수록 VisualElement와 이벤트 수가 증가하므로 최상단 툴팁 하나를 재사용하는 편이 단순하고 안정적이다.

worldBound 대신 layout을 사용해도 될까?

툴팁 위치는 포인터의 패널 좌표와 비교해야 하므로 worldBound가 적합하다. layout은 부모 기준 좌표와 크기다.

터치 기기에서는 어떤 이벤트를 써야 할까?

호버가 없는 터치 환경에서는 슬롯 탭 시 툴팁을 열고 화면의 다른 영역을 탭하거나 닫기 버튼을 누르면 닫는 방식이 자연스럽다.

정리

반응형 UI Toolkit 툴팁의 핵심은 슬롯 내부가 아닌 루트 오버레이에 툴팁 하나를 두는 것이다. PointerEventBase.position, worldBound, Mathf.Clamp만으로 화면 경계 보정을 처리할 수 있으며 인벤토리 슬롯은 UXML 템플릿과 데이터 주입으로 충분하다.

커스텀 드래그 앤 드롭과 풀링은 슬롯 수나 프로파일링 결과가 실제로 문제를 보일 때 추가하면 된다.

#Unity#UI Toolkit#Unity 인벤토리#반응형 툴팁#C##게임 UI

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs