슬롯 클릭 인터랙션(판매, 장착, 소비아이템 장착, 직접 사용)

목차

1. 요구 사항

2. 설계 목표

3. 흐름도

4. 구현

        4.1. 인벤토리 슬롯에서 클릭

       4.2. 소비 아이템 사용 및 쿨타임 UI

       4.3. 쿨타임 갱신 구조

       4.4. 슬롯 쿨타임 UI 갱신 로직

       4.5. 인벤토리에서 소비 아이템 사용 처리

5. 개발 의도

1. 시스템 요구 사항

인벤토리 슬롯은 단순한 데이터 표시 UI가 아니라, 플레이어의 실제 전투·상점·장비 시스템과 연결되는 입력 허브 역할을 수행해야 한다.

슬롯 위에서 발생하는 클릭 입력은 현재 게임 맥락(상점 UI 활성 여부), 아이템 타입(장비/소비), 클릭 종류(좌/우)에 따라 서로 다른 동작으로 분기되어야 한다.

소비 아이템의 경우 단순히 수량을 감소시키는 것이 아니라, 사용 가능 여부를 중앙 시스템에서 판정한 뒤 실제 회복 효과를 적용하고, 동일 아이템 ID 기준의 쿨타임을 시작해야 한다.

이 쿨타임은 슬롯 기준이 아니라 아이템 종류 기준으로 공유되어야 하며, 어떤 슬롯에 동일 포션이 있더라도 동일한 남은 시간을 표시해야 한다.

또한, 포션 사용이 실패했을 경우(쿨타임 중, 스탯 최대치 등)에는 수량이 감소하면 안 되며, UI 역시 일관된 상태를 유지해야 한다.

모든 소비 성공 시에는 인벤토리 수량 감소, 슬롯 비우기, 쿨타임 UI 표시, HUD 스탯 갱신이 하나의 흐름으로 자연스럽게 이어져야 한다.

2. 설계  목표

- 슬롯은 입력 해석만 담당하고, 실제 도메인 로직은 전용 매니저(PotionManager 등)로 위임한다.

- 포션 사용 가능 판정과 실제 효과 적용을 분리해, 규칙을 단일화한다.

- 쿨타임은 슬롯 기준이 아닌 ItemId 기준으로 관리해 동일 포션 간 규칙을 통일한다.

- UI는 데이터 변경 결과만 반영하며, 자체적으로 상태를 보관하지 않는다.

- 소비 실패 시 수량 감소가 절대 발생하지 않도록 이중 방어 구조를 둔다.

3. 흐름도

이 흐름에서 중요한 점은 '입력 → 도메인 판정 → 데이터 변경 → UI 반영'의 방향이 단방향이라는 것이다.

UI가 직접 스탯을 변경하지 않고, 슬롯이 직접 쿨타임을 계산하지 않는다.

모든 판단은 중앙 시스템에서 이루어지고, UI는 결과를 표현한다.

4. 구현

4.1. 인벤토리 슬롯에서 클릭
// InventorySlot.cs
public void OnPointerClick(PointerEventData eventData)
{
    if (slotData == null || slotData.IsEmpty || slotData.data == null) return;
    
    var itemData = slotData.data;
    
    // 오른쪽 클릭 : 장비 장착 / 소비 아이템 장착 등
    if (eventData.button == PointerEventData.InputButton.Right)
    {
        if (UI_Manager.instance.shopUI.activeSelf)
        {
            sellUimanager.SellSetting(slotData);
        }
        else
        {
            if (itemData.InventoryType == InventoryItemData.InventoryItemType.Equipment)
            {
                if (equipPanel != null && equipPanel.activeSelf) return;
                StatusWeaponSlot.instance.EquipWeaponBySlot(SlotIndex);
            }
            else if (itemData.InventoryType == InventoryItemData.InventoryItemType.Consumable)
            {
                fitItemUiManager.FitItemFromSlot(slotData, SlotIndex);
            }
        }
    }
    
    // 왼쪽 클릭 : 포션 사용
    else if (eventData.button == PointerEventData.InputButton.Left)
    {
        if (itemData.InventoryType == InventoryItemData.InventoryItemType.Consumable)
        {
            // 인벤토리 슬롯에서 직접 포션 사용
            InventoryManager.instance.UseConsumableFromSlot(SlotIndex);
        }
    }
}

OnPointerClick은 Unity의 EventSystem에서 제공하는 IPointerClickHandler 기반 콜백이다.

이는 Update에서 마우스 입력을 직접 감시하는 폴링 방식이 아니라, 이벤트 기반으로 동작한다는 점이 중요하다.

이벤트 기반 입력은 입력이 발생한 순간에만 실행되므로 불필요한 프레임 연산을 줄일 수 있고, 입력의 의미 단위가 명확하게 분리된다.

단점은 EventSystem 설정이나 Raycast 레이어 구성이 잘못되면 이벤트가 전달되지 않는다는 점이지만, 인벤토리 슬롯처럼 UI 상호작용이 명확한 영역에서는 오히려 구조적 안정성이 높다.

가장 먼저 슬롯 데이터 유효성을 검사하는 부분은 단순한 방어 코드가 아니다.

UI는 항상 데이터와 동기화되어 있다고 가정할 수 없다.

드래그 도중, 자동 정렬, 외부 시스템 호출 등으로 인해 슬롯 상태가 바뀌었을 가능성을 고려해야 한다.

slotData가 null이거나, 비어 있거나, 내부 data가 null인 경우를 선제적으로 차단함으로써 NullReferenceException 가능성을 제거한다.

이 단계에서 이미 아이템이 존재하는 슬롯이라는 전제가 확정된다.

eventData.button 분기는 좌클릭과 우클릭의 UX 정책을 코드로 고정하는 부분이다.

PointerEventData.InputButton은 Unity에서 정의된 열거형이며, 입력 장치 종류를 명확히 구분할 수 있다.

여기서 우클릭은 상호작용 계열, 좌클릭은 즉시 사용 계열이라는 규칙을 구현했다.

우클릭의 경우 먼저 상점 UI가 열려 있는지를 검사한다.

UI_Manager.instance.shopUI.activeSelf를 확인하는 이유는 동일 입력이라도 현재 UI 상태에 따라 의미가 달라지기 때문이다.

상점이 열려 있다면 우클릭은 판매 행동이 된다.

이처럼 입력 해석은 항상 현재 UI 컨텍스트에 종속된다.

이 분기 구조는 UI 상태 기반 입력 해석이라는 설계 의도를 보여준다.

상점이 열려 있지 않은 경우, 아이템 타입에 따라 다시 분기한다.

InventoryType을 기준으로 Equipment와 Consumable을 구분한다.

이 설계는 타입 기반 행동 분리다.

아이템 데이터가 자신의 카테고리를 정의하고 있고, UI는 그 정보를 기반으로 적절한 시스템으로 위임한다.

장비 타입은 StatusWeaponSlot으로 위임하여 장착 로직으로 연결되고, 소비 아이템은 FitItemUiManager로 전달되어 퀵슬롯 장착 UI로 이어진다.

중요한 점은 InventorySlot이 장착 세부 로직을 직접 구현하지 않는다는 것이다.

항상 전용 매니저로 위임한다.

좌클릭은 소비 아이템에 대해서만 반응한다.

이때 InventoryManager.instance.UseConsumableFromSlot을 호출한다.

InventorySlot은 소비 효과를 직접 적용하지 않는다.

입력 해석까지만 담당하고, 실제 도메인 로직은 중앙 관리자인 InventoryManager와 PotionManager가 처리한다.

이는 입력 계층과 도메인 계층의 분리를 유지하기 위한 설계다.

4.2. 소비 아이템 사용 및 쿨타임 UI
// PotionManager.cs
// 쿨타임 데이터 저장소(공통 규칙의 근거 데이터)
private readonly Dictionary<int, float> cooldowns = new Dictionary<int, float>();

public bool CanUse(PotionItemData potionData)
{
    if (potionData == null) return false;

    int itemId = potionData.ItemId;

    // 1) 쿨타임 중이면 사용 불가임
    if (cooldowns.TryGetValue(itemId, out float remain) && remain > 0f)
        return false;

    // 2) 스탯이 이미 가득 찼으면 사용 불가임
    if (potionData.RecoveryType == PotionItemData.RecoveryItemType.Hp)
    {
        if (PlayerManager.instance.CurrentHp >= PlayerManager.instance.MaxHp)
            return false;
    }
    else if (potionData.RecoveryType == PotionItemData.RecoveryItemType.Mp)
    {
        if (PlayerManager.instance.CurrentMp >= PlayerManager.instance.MaxMp)
            return false;
    }

    return true;
}

public bool UsePotion(PotionItemData potionData)
{
    if (!CanUse(potionData)) return false;

    // 3) 회복 적용임
    if (potionData.RecoveryType == PotionItemData.RecoveryItemType.Hp)
    {
        int heal = Mathf.CeilToInt(PlayerManager.instance.MaxHp * potionData.RecoveryPercentage / 100f);
        PlayerManager.instance.CurrentHp =
            Mathf.Min(PlayerManager.instance.CurrentHp + heal, PlayerManager.instance.MaxHp);
    }
    else if (potionData.RecoveryType == PotionItemData.RecoveryItemType.Mp)
    {
        int heal = Mathf.CeilToInt(PlayerManager.instance.MaxMp * potionData.RecoveryPercentage / 100f);
        PlayerManager.instance.CurrentMp =
            Mathf.Min(PlayerManager.instance.CurrentMp + heal, PlayerManager.instance.MaxMp);
    }

    // 4) 쿨타임 시작임 (같은 ItemId 공유 규칙임)
    cooldowns[potionData.ItemId] = potionData.CooldownTime;

    // 5) 스탯 UI 즉시 갱신임
    HUD_StatManager.instance.UpdateStatusUI();

    return true;
}

cooldowns는 포션 ID를 키로, 남은 쿨타임을 값으로 가지는 자료구조다.

Dictionary를 사용한 이유는 특정 ItemId에 대한 조회가 O(1)에 가깝기 때문이다.

슬롯 인덱스 기반이 아니라 ItemId 기반으로 설계한 것은 같은 포션은 어디에 있든 동일 쿨타임을 공유한다는 규칙을 코드 구조에 반영한 것이다.

Dictionary를 readonly로 선언한 것은 외부에서 재할당을 막기 위함이며, IReadOnlyDictionary로 노출하는 것은 외부 수정 차단을 위한 설계다.

이는 데이터 무결성을 보호하기 위한 계층 분리 표현이다.

CanUse는 사용 가능 여부를 판정하는 전용 함수다.

쿨타임 중인지 확인하고, HP 또는 MP가 이미 최대치인지 확인한다.

이 함수가 중요한 이유는 사용 실패 조건을 단일 위치에서 정의한다는 점이다.

사용 불가 조건이 여러 곳에 흩어지면 유지보수가 어려워진다.

이 함수는 판정 전용이며, 상태 변경을 수행하지 않는다.

UsePotion은 실제 상태를 변경하는 함수다.

내부에서 CanUse를 다시 호출하는 이중 방어 구조다.

UI나 다른 시스템이 CanUse를 건너뛰고 직접 UsePotion을 호출하더라도, 도메인 계층은 스스로를 보호한다.

회복량 계산에서 Mathf.CeilToInt를 사용하는 것은 UX 고려다.

퍼센트 기반 회복에서 소수점이 발생할 경우 내림을 하면 체감이 줄어든다.

올림은 항상 최소 1 이상의 체감을 보장한다.

이후 Mathf.Min을 사용해 최대 스탯을 초과하지 않도록 상한을 강제한다.

이 두 줄은 과회복과 수치 왜곡을 동시에 방지하는 안전 장치다.

쿨타임 시작은 cooldowns[potionData.ItemId] = potionData.CooldownTime으로 처리한다.

같은 ID를 덮어쓰는 구조이므로 동일 포션은 항상 하나의 쿨타임만 가진다. 이 설계는 규칙을 단순화한다.

마지막으로 HUD_StatManager.instance.UpdateStatusUI()를 호출해 즉시 UI를 갱신한다.

회복과 동시에 시각적 피드백이 발생하도록 한 구조다.

4.3. 쿨타임 갱신 구조
// PotionManager.cs
private readonly Dictionary<int, float> cooldowns = new Dictionary<int, float>();
public IReadOnlyDictionary<int, float> Cooldowns => cooldowns;

public void Tick(float dt)
{
    if (cooldowns.Count == 0) return;

    var keys = new List<int>(cooldowns.Keys);
    foreach (int id in keys)
    {
        cooldowns[id] -= dt;
        if (cooldowns[id] <= 0f)
            cooldowns.Remove(id);
    }
}


// PotionUIManager.cs
void Update()
{
    PotionManager.Instance.Tick(Time.deltaTime);

    var cooldowns = PotionManager.Instance.Cooldowns;
    
    if (SlotManager.instance != null)
    {
        SlotManager.instance.UpdatePotionCooldownUI(PotionManager.Instance.Cooldowns);
    }
    
    // ...
}

PotionManager는 순수 로직 클래스다.

MonoBehaviour를 상속하지 않기 때문에 Update를 가질 수 없다.

따라서 시간 기반 감소는 외부에서 주입받는 방식으로 설계했다.

dt는 Time.deltaTime이 전달된다.

Dictionary를 순회할 때 바로 Remove를 수행하면 컬렉션 수정 예외가 발생한다.

그래서 new List<int>(cooldowns.Keys)로 키 복사본을 만든 뒤 순회한다.

이 방식은 안전하지만, 매 프레임 List를 생성하므로 GC 비용이 있다.

그러나 쿨타임 개수가 많지 않다는 전제에서 충분히 허용 가능한 비용이다.

Update를 사용하는 이유는 쿨타임이 시간 기반이기 때문이다.

이벤트 기반으로 처리할 수 없는 영역이다.

매 프레임 dt를 전달해 남은 시간을 감소시키는 것이 가장 자연스럽다.

Tick을 먼저 수행하고 UI 갱신을 호출하는 순서는 중요하다.

항상 최신 상태가 UI에 반영되도록 보장한다.

Update를 UI 매니저 쪽에 둔 이유는 PotionManager를 순수 로직 계층으로 유지하기 위함이다.

도메인 계층은 Unity 생명주기에 의존하지 않는다.

시간 흐름은 외부에서 주입받는다.

이는 테스트 가능성과 확장성을 고려한 구조다.

4.4. 슬롯 쿨타임 UI 갱신 로직
// SlotManager.cs
public void UpdatePotionCooldownUI(IReadOnlyDictionary<int, float> cooldowns)
{
    for (int i = 0; i < slots.Count; i++)
    {
        var ui = slots[i];
        var slotData = InventoryManager.instance.GetSlot(i);

        if (slotData == null || slotData.IsEmpty || slotData.data == null)
        {
            ui.coolTimeImage.enabled = false;
            ui.coolTimeText.enabled = false;
            ui.coolTimeImage.fillAmount = 0f;
            ui.coolTimeText.text = "";
            continue;
        }

        var potionData = slotData.data as PotionItemData;
        if (potionData == null)
        {
            ui.coolTimeImage.enabled = false;
            ui.coolTimeText.enabled = false;
            ui.coolTimeImage.fillAmount = 0f;
            ui.coolTimeText.text = "";
            continue;
        }

        float remain;
        if (!cooldowns.TryGetValue(potionData.ItemId, out float remain) || remain <= 0f)
        {
            ui.coolTimeImage.enabled = false;
            ui.coolTimeText.enabled = false;
            ui.coolTimeImage.fillAmount = 0f;
            ui.coolTimeText.text = "";
        }
        else
        {
            ui.coolTimeImage.enabled = true;
            ui.coolTimeText.enabled = true;
            ui.coolTimeImage.fillAmount = Mathf.Clamp01(remain / potionData.CooldownTime);
            ui.coolTimeText.text = Mathf.CeilToInt(remain).ToString();
        }
    }
}

이 함수는 쿨타임을 계산하는 함수가 아니라, 이미 계산된 데이터를 표현하는 함수다.

이 차이를 명확히 하는 것이 이 구조의 핵심이다.

UpdatePotionCooldownUI는 SlotManager에 존재하며, 인벤토리 슬롯 UI 전체를 순회하면서 각 슬롯의 쿨타임 표시 상태를 동기화한다.

여기서 중요한 설계 포인트는 이 함수가 쿨타임 값을 직접 감소시키지 않는다는 점이다.

쿨타임 감소는 PotionManager의 Tick에서만 이루어지고, 이 함수는 오직 IReadOnlyDictionary<int, float> 형태로 전달받은 결과만을 기반으로 UI를 갱신한다.

IReadOnlyDictionary를 사용하는 이유는 명확하다.

UI 계층은 쿨타임 데이터를 수정할 권한이 없기 때문이다.

Dictionary를 그대로 노출하면 UI 코드에서 값이 변경될 가능성이 생기지만, 읽기 전용 인터페이스로 제한하면 구조적으로 데이터 오염 가능성을 차단할 수 있다.

이는 계층 분리의 중요한 표현이다.

슬롯을 for 루프로 순회하는 이유는 슬롯 인덱스 기반 구조를 유지하기 위함이다.

foreach 대신 for를 사용한 것은 슬롯 인덱스를 InventoryManager와 직접 대응시키기 위함이다.

GetSlot(i) 호출을 통해 데이터 슬롯과 UI 슬롯을 동일 인덱스로 매칭시킨다.

슬롯이 비어 있거나, 아이템 데이터가 없거나, 포션이 아닌 경우에는 반드시 쿨타임 UI를 비활성화하고 fillAmount와 텍스트를 초기화한다.

이 초기화 코드가 중요한 이유는 이전 프레임 잔상 제거 때문이다.

만약 포션을 사용한 뒤 슬롯이 비워졌는데 쿨타임 UI를 끄지 않으면, 화면에는 여전히 게이지가 남아 있는 상태가 된다.

이 코드는 매 프레임 완전한 상태 재바인딩을 수행함으로써 UI 정합성을 보장한다.

TryGetValue는 C# Dictionary의 안전 조회 방식이다.

키가 존재하지 않을 경우 예외를 던지지 않고 false를 반환한다.

이 방식을 사용함으로써 쿨타임이 존재하지 않는 상태를 자연스럽게 쿨타임 없음으로 해석할 수 있다.

fillAmount는 Unity UI Image 컴포넌트의 Filled 타입에서 사용하는 프로퍼티다.

0~1 범위 값을 기대하므로 Mathf.Clamp01로 안전하게 제한한다.

remain / CooldownTime은 현재 남은 비율을 의미하며, 시각적으로 남은 시간을 직관적으로 표현한다.

텍스트는 Mathf.CeilToInt(remain)으로 올림 처리한다.

이 선택은 UX 측면의 결정이다.

만약 floor를 사용하면 0.2초가 남은 상태에서도 0으로 표시되어 사용 가능한 것처럼 보이지만 실제로는 사용 불가라는 혼란을 초래할 수 있다.

올림은 항상 실제 남은 시간보다 과소 표현되지 않도록 보장한다.

4.5. 인벤토리에서 소비 아이템 사용 처리
// InventoryManager.cs
public void UseConsumableFromSlot(int slotIndex)
{
    var slot = GetSlot(slotIndex);
    if (slot == null || slot.IsEmpty || slot.data == null) return;

    var potionData = slot.data as PotionItemData;
    if (potionData == null) return;

    // 중앙 시스템에서 판정/효과/쿨타임 시작을 수행함
    if (!PotionManager.Instance.UsePotion(potionData))
        return;

    // 성공 시에만 소모 처리함
    slot.count--;
    if (slot.count <= 0)
        slot.Clear();

    // 인벤 UI 즉시 갱신임
    SlotManager.instance.PoplateSlots();
}

이 함수는 '슬롯 입력 → 실제 데이터 변경' 으로 넘어가는 유일한 진입점이다.

먼저 슬롯을 가져온 뒤 null, empty, data null을 검사한다.

이 검사는 단순해 보이지만 매우 중요하다.

인벤토리 시스템은 다양한 UI 흐름과 상호작용하기 때문에, 슬롯 상태는 언제든 변할 수 있다.

입력 시점과 처리 시점 사이의 시간차를 고려해, 항상 데이터 유효성을 재검증하는 구조를 유지한다.

그 다음 as 캐스팅을 사용해 PotionItemData로 변환한다.

C#의 as 키워드는 실패 시 null을 반환한다.

직접 캐스팅을 사용하면 InvalidCastException이 발생할 수 있지만, as는 안전하게 null을 반환하므로 이후 null 검사로 흐름을 제어할 수 있다.

이 방식은 런타임 안정성을 높인다.

핵심은 PotionManager.Instance.UsePotion(potionData) 호출이다.

이 호출은 실제 사용 가능 판정, 회복 적용, 쿨타임 시작을 모두 포함한다.

UseConsumableFromSlot은 그 결과만을 신뢰한다.

성공 여부가 true일 때만 수량을 감소시킨다.

이 구조는 매우 중요하다.

만약 UsePotion이 실패했는데도 slot.count를 먼저 감소시키면, 쿨타임 중인데 포션이 사라지는 치명적인 버그가 발생한다.

따라서 데이터 변경은 항상 중앙 판정 이후에만 이루어진다.

count 감소 이후 0 이하가 되면 Clear()를 호출한다.

여기서 Clear는 단순히 count를 0으로 만드는 것이 아니라 data 레퍼런스까지 null로 만든다.

이는 슬롯 상태를 완전히 초기화하는 정규화 동작이다.

data가 남아 있고 count만 0인 상태는 IsEmpty 정의에 따라 다른 해석을 낳을 수 있으므로, Clear는 슬롯 상태를 확정적으로 비어 있음으로 만든다.

마지막으로 SlotManager.instance.PoplateSlots()를 호출한다.

이 호출은 UI 재바인딩의 단일 진입점이다.

슬롯 UI를 직접 수정하지 않고, 항상 데이터 전체를 다시 반영한다.

이 설계는 UI 상태와 데이터 상태의 불일치를 원천적으로 차단한다.

5. 개발 의도

이 게시글의 핵심은 슬롯은 입력 해석기이고, 규칙은 중앙 시스템에 존재한다는 구조를 명확히 드러내는 데 있다.

소비 아이템 사용 실패 시 수량 감소가 일어나지 않도록 이중 방어 구조를 두었고, 쿨타임을 ItemId 기준으로 관리해 슬롯 이동이나 분할과 독립적인 규칙을 유지했다.

또한 Update를 UI 매니저 쪽에만 두고, PotionManager는 순수 로직으로 유지함으로써 도메인 로직과 Unity 생명주기 의존성을 분리했다.

이 구조는 테스트 가능성과 확장성 측면에서 유리하다.