Unreal

C++을 사용한 언리얼엔진 인벤토리 시스템 Part.5

story98138 2026. 3. 18. 20:10
1. ItemBase — 아이콘 변수 추가
 

인벤토리 UI에 아이템 이미지를 표시하려면, 각 아이템이 자신의 아이콘을 가지고 있어야 합니다. AItemBase에 아이콘 변수와 Getter를 추가했습니다.

 
왜 UTexture2D가 아닌 UMaterialInterface?
텍스처를 직접 쓰면 회전·색조 변화 같은 효과를 주기 어렵다. 머티리얼로 감싸면 파라미터 하나로 회전 애니메이션, 색 보정 등을 자유롭게 적용할 수 있다.
// ItemBase.h

// protected 영역에 추가
UPROPERTY(EditAnywhere, Category = "Item Info | Icon")
UMaterialInstance* Icon;

// public 영역에 Getter 선언
UMaterialInstance* GetIcon();

 

// ItemBase.cpp

UMaterialInstance* AItemBase::GetIcon()
{
    return Icon;
}

2. 아이콘 머티리얼 세팅 (에디터)

C++ 작업 후, 에디터에서 아이콘에 사용할 머티리얼을 제작했습니다.

1.텍스처 임포트
  • AK-47, 수류탄, 대검 아이콘 PNG 임포트 → Texture Group을 UI로 설정 (UI 렌더링 최적화)
2.베이스 머티리얼 M_IconBase 생성
  • Material Domain → User Interface / Blend Mode → Translucent
  • Custom Rotator 노드 + 텍스처 파라미터화 → 회전 효과를 인스턴스에서 조절 가능하게 구성
3.아이템별 머티리얼 인스턴스 생성
  • M_IconBase를 상속 → MI_AK47, MI_Grenade, MI_Knife 각각 생성
  • 각 인스턴스에 해당 아이콘 텍스처 할당 후 BP_아이템의 Icon 슬롯에 연결

3. ItemWidget C++ 클래스 구성

인벤토리 칸 하나에 아이템 아이콘을 표시하는 UItemWidget을 구현했다. BindWidget으로 C++ 변수와 블루프린트 위젯을 연결합니다.

 
// ItemWidget.h 

class UCanvasPanel;
class USizeBox;
class UBorder;
class UImage;

UCLASS()
class UItemWidget : public UUserWidget
{
    GENERATED_BODY()

protected:
    // BindWidget — 블루프린트의 위젯 이름과 반드시 일치해야 함
    UPROPERTY(VisibleAnywhere, meta = (BindWidget))
    UCanvasPanel* Canvas;

    UPROPERTY(VisibleAnywhere, meta = (BindWidget))
    USizeBox* BackgroundSizeBox;

    UPROPERTY(VisibleAnywhere, meta = (BindWidget))
    UBorder* BackgroundBorder;

    UPROPERTY(VisibleAnywhere, meta = (BindWidget))
    UImage* ItemImage;

    AIntentorySystemCppCharacter* CharacterReference;
    FVector2D Size;

    virtual void NativeConstruct() override;
    void Refresh(AActor* ItemToAdd);
};
 
// ItemWidget.cpp

void UItemWidget::NativeConstruct()
{
    Super::NativeConstruct();

    CharacterReference = Cast<AIntentorySystemCppCharacter>(
        UGameplayStatics::GetPlayerCharacter(GetWorld(), 0));

    if (CharacterReference) {
        Refresh(CharacterReference->ItemToAdd);
    }
}

void UItemWidget::Refresh(AActor* ItemToAdd)
{
    AItemBase* Item = Cast<AItemBase>(ItemToAdd);
    if (!Item) return;

    // 아이콘 머티리얼을 Image에 적용
    ItemImage->SetBrushFromMaterial(Item->GetIcon());

    // 아이템 크기(타일 수 × TileSize)로 위젯 사이즈 결정
    Size = FVector2D(
        Item->GetDimensions().X * CharacterReference->InventoryComponent->TileSize,
        Item->GetDimensions().Y * CharacterReference->InventoryComponent->TileSize
    );

    BackgroundSizeBox->SetWidthOverride(Size.X);
    BackgroundSizeBox->SetHeightOverride(Size.Y);

    UCanvasPanelSlot* ImageAsCanvasSlot =
        UWidgetLayoutLibrary::SlotAsCanvasSlot(ItemImage);
    ImageAsCanvasSlot->SetSize(Size);
}
 
BindWidget 바인딩 규칙
C++에서 선언한 변수명(BackgroundSizeBox, ItemImage 등)과 블루프린트 BPW_Item에서 배치한 위젯의 이름이 완전히 동일해야 컴파일 에러 없이 바인딩됩니다.
 

4. InventoryComponent — TMap 기반 아이템 관리

단순 배열(TArray)만으로는 "이 아이템이 인벤토리의 어느 좌표에 있는가"를 빠르게 알 수 없다. 아이템 포인터를 키, 타일 좌표를 값으로 하는 TMap을 추가했습니다.

<TArray만 사용할 때>
Items[index] = ItemPtr 아이템의 위치를 구하려면 전체 배열을 순회해야 해서 비효율적
 
<TMap 추가 후>
AllItems[ItemPtr] = FIntPoint(X, Y) 포인터 하나로 타일 좌표를 O(1)에 바로 조회 가능
 
 
// InventoryComponent.h

bool AddedItem = false;   // UI 업데이트 트리거 플래그

protected:
    TMap<AItemBase*, FIntPoint> AllItems;  // 아이템 → 타일 좌표
 
// InventoryComponent.cpp — AddItemAt()

void UInventoryComponent::AddItemAt(AItemBase* ItemToAdd, int32 TopLeftIndex)
{
    FIntPoint Dimensions = ItemToAdd->GetDimensions();
    FIntPoint Tile = IndexToTile(TopLeftIndex);

    for (int32 i = Tile.X; i <= Tile.X + Dimensions.X - 1; i++) {
        for (int32 j = Tile.Y; j <= Tile.Y + Dimensions.Y - 1; j++) {
            Items[TileToIndex(FIntPoint(i, j))] = ItemToAdd;
        }
    }
    AddedItem = true;   // ← Tick에서 Refresh() 호출 트리거
}

TMap<AItemBase*, FIntPoint> UInventoryComponent::GetAllItems()
{
    for (int32 i = 0; i < Items.Num(); i++) {
        if (Items[i] && !AllItems.Contains(Items[i])) {
            AllItems.Add(Items[i], IndexToTile(i));
        }
    }
    return AllItems;
}

 

 

 
// InventoryComponent.cpp — TickComponent() 

void UInventoryComponent::TickComponent(...)
{
    Super::TickComponent(DeltaTime, TickType, ThisTickFunction);

    if (AddedItem) {
        if (InventoryGridWidgetReference) {
            InventoryGridWidgetReference->Refresh();
        }
        AddedItem = false;
    }
}
 
 
왜 Tick에서 Refresh를 호출하나?
OnBeginOverlap 콜백 내부에서 위젯을 직접 갱신하면 물리/콜리전 처리 도중 UI 코드가 실행되어 의도치 않은 순서 문제가 생길 수 있다. 플래그를 세우고 Tick에서 처리하면 프레임 단위로 안전하게 UI를 갱신할 수 있습니다.
 

5. InventoryGridWidget — Refresh() 로직

아이템이 추가될 때마다 그리드 캔버스를 전부 지우고 현재 인벤토리 상태를 다시 그립니다.

 
// InventoryGridWidget.cpp — Refresh()

void UInventoryGridWidget::Refresh()
{
    // 1. 기존 위젯 전부 제거
    GridCanvasPanel->ClearChildren();

    // 2. 현재 인벤토리의 아이템 목록 가져오기
    TArray<AItemBase*> Keys;
    InventoryComponent->GetAllItems().GetKeys(Keys);

    if (!CharacterReference->ItemWidgetClass) return;

    for (AItemBase* AddedItem : Keys)
    {
        // 3. 아이템마다 새 위젯 생성
        UUserWidget* NewItemWidget = CreateWidget(
            GetWorld(), CharacterReference->ItemWidgetClass);
        NewItemWidget->SetOwningPlayer(GetOwningPlayer());

        // 4. 타일 좌표 → 픽셀 좌표 변환
        int32 X = InventoryComponent->GetAllItems()[AddedItem].X
                  * InventoryComponent->TileSize;
        int32 Y = InventoryComponent->GetAllItems()[AddedItem].Y
                  * InventoryComponent->TileSize;

        // 5. 캔버스에 추가 후 위치 설정
        UPanelSlot* NewSlot = GridCanvasPanel->AddChild(NewItemWidget);
        Cast<UCanvasPanelSlot>(NewSlot)->SetAutoSize(true);
        Cast<UCanvasPanelSlot>(NewSlot)->SetPosition(FVector2D(X, Y));
    }
}
1.ClearChildren() — 중복 방지
  • Refresh가 호출될 때마다 기존 위젯을 전부 제거. 없으면 아이템을 먹을 때마다 위젯이 쌓임
2.GetAllItems() — TMap에서 키 목록 추출
  • GetKeys()로 현재 인벤토리에 있는 아이템 포인터 목록을 가져옴
3.CreateWidget() — 아이템마다 개별 생성
  • 루프 안에서 매번 새 위젯을 생성해야 함. 루프 밖에서 1개 생성 후 재사용하면 UMG 특성상 마지막 부모에만 붙음
4.타일 좌표 → 픽셀 좌표 변환
  • TileX × TileSize, TileY × TileSize → 캔버스 위의 절대 픽셀 위치
5.SlotAsCanvasSlot() → SetPosition()
  • AddChild()의 반환값을 UCanvasPanelSlot으로 캐스팅해야 위치/크기 조절 함수를 쓸 수 있음

6. 에디터 연결 및 테스트

코드 빌드 후 에디터에서 두 가지를 연결해야 정상 동작합니다.

 
BP_ThirdPersonCharacter Details 패널 확인
  • Item Widget Class  BPW_Item (아이템 1칸짜리 위젯)
  • Inventory Widget Class → BPW_Inventory (전체 인벤토리 위젯)

    둘 중 하나라도 None이거나 잘못 설정하면 위젯이 생성되지 않는다. 특히 Item Widget Class에 InventoryWidget을 잘못 넣는 실수를 주의할 것.
 
테스트 결과
  • AK-47 획득 → 인벤토리 그리드에 2×1 크기로 아이콘 표시
  • 대검 획득 → 1×1 크기로 아이콘 표시
  • 아이템을 먹을 때마다 Refresh가 트리거되어 실시간으로 인벤토리 업데이트 확인

7. 오늘 배운 점 & 회고

  • UMaterialInterface vs UTexture2D — 머티리얼로 감싸면 파라미터 기반 회전·색조 제어가 가능해진다
  • BindWidget — C++ 변수명과 블루프린트 위젯 이름이 정확히 일치해야 바인딩된다
  • TMap<AItemBase*, FIntPoint> — 아이템 위치를 O(1)로 조회할 수 있어 Refresh 로직에서 유용하다
  • AddedItem 플래그 패턴 — Overlap 콜백 안이 아닌 Tick에서 UI를 갱신하면 실행 순서가 안전해진다
  • CreateWidget은 루프 안에서 — UMG 위젯은 부모를 하나만 가질 수 있으므로 아이템마다 새로 생성해야 한다
  • SlotAsCanvasSlot() 캐스팅 — AddChild() 반환값을 UCanvasPanelSlot으로 캐스팅해야 위치/크기를 지정할 수 있다