Unreal

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

story98138 2026. 3. 13. 19:01

1. 오늘의 개발 개요

Part 3에서 아이템 클래스와 오버랩 감지를 완성한 데 이어, 이번 파트에서는 인벤토리 시스템의 핵심 백엔드 로직을 구현했습니다. 아이템 크기(FIntPoint Dimensions) 정의부터, 1차원 배열을 2차원 그리드로 매핑하는 좌표 변환, 공간 가용성 체크, 실제 배열 등록까지 완성했습니다.

 


2. 아이템 크기 정의 — FIntPoint Dimensions

각 아이템이 인벤토리 그리드에서 차지하는 칸 수를 FIntPoint로 선언했습니다. FVector2D와 유사하지만 정수형(X, Y)이라 타일 인덱스 계산에 적합합니다. EditAnywhere로 노출해 블루프린트마다 개별 설정이 가능합니다.

 

// ItemBase.h

protected:
    UPROPERTY(EditAnywhere, Category = "Item Info | Dimensions")
    FIntPoint Dimensions;   // X = 가로 칸 수, Y = 세로 칸 수

public:
    FIntPoint GetDimensions() const;

 

// ItemBase.cpp

FIntPoint AItemBase::GetDimensions() const
{
    return Dimensions;
}

 

 

블루프린트별 아이템 크기 설정 예시

아이템 Dimensions.X (가로) Dimensions.Y (세로)
AK-47 4 2
수류탄 1 1
단검 1 2

 

 


3. Items 배열 선언 & 초기화

인벤토리의 각 타일과 1:1로 대응하는 TArray<AItemBase*> Items를 InventoryComponent에 선언합니다. 배열 크기는 Columns × Rows이며, BeginPlay 시점에 모두 nullptr로 초기화합니다. 초기화는 InventoryComponent의 BeginPlay가 아닌 캐릭터의 BeginPlay에서 처리한 점이 핵심입니다.

 

// InventoryComponent.h

TArray<AItemBase*> Items;   // 타일 수만큼 크기, 빈 타일 = nullptr

bool        TryAddItem(AItemBase* ItemToAdd);
bool        IsRoomAvailable(AItemBase* ItemToAdd, int32 Index);
FIntPoint   IndexToTile(int32 TopLeftIndex);
bool        IsTileValid(FIntPoint Tile);
int32       TileToIndex(FIntPoint Tile);
bool        GetResultAtIndex(int32 Index);
AItemBase*  GetItemAtIndex(int32 Index);
void        AddItemAt(AItemBase* ItemToAdd, int32 TopLeftIndex);

 

// IntentorySystemCppCharacter.cpp — BeginPlay에서 배열 크기 확정

void AIntentorySystemCppCharacter::BeginPlay()
{
    Super::BeginPlay();

    // ... 위젯 생성 코드 ...

    InventoryComponent->Items.Init(nullptr, 
        InventoryComponent->Columns * InventoryComponent->Rows);
    // Columns=10, Rows=6이면 Items 크기 = 60, 전부 nullptr로 초기화
}

 

 

※ Init() vs SetNum()

  • Items.Init(nullptr, N) — 크기를 N으로 설정하고 모든 요소를 nullptr로 채움. 빈 인벤토리 초기화에 적합.
  • Items.SetNum(N) — 크기만 N으로 맞추고 기존 값 유지. 포인터 배열의 경우 초기값이 보장되지 않아 Init이 더 안전.

4. 1D ↔ 2D 좌표 변환 — IndexToTile / TileToIndex

// InventoryComponent.cpp

FIntPoint UInventoryComponent::IndexToTile(int32 Index)
{
    // X = 열(Column), Y = 행(Row)
    return FIntPoint(Index % Columns, Index / Columns);
}

int32 UInventoryComponent::TileToIndex(FIntPoint Tile)
{
    return Tile.X + Tile.Y * Columns;
}

 

 

예시: Columns = 4인 인벤토리에서의 인덱스 → 타일 매핑

▲ 
진하게 표시된 칸 = 2×2 아이템이 점유한 타일 (idx 6,7,10,11)

 

 

 변환 공식 직관적으로 이해하기

  • Index → Tile: X = Index % Columns (열 위치), Y = Index / Columns (행 위치 — 정수 나눗셈)
  • Tile → Index: X + Y * Columns — Y행에서 X번째 칸의 절대 위치
  • 이 두 함수가 없으면 이후 모든 공간 계산 로직이 작동 불가 — 인벤토리 시스템의 수학적 기반

 


5. 공간 확인 로직 — IsRoomAvailable

특정 인덱스를 좌상단(TopLeft)으로 삼아 아이템 크기만큼의 공간이 비어 있는지 확인합니다. 이중 for문으로 아이템이 점유할 모든 타일을 순회하며 두 가지 조건을 체크합니다.

 

// InventoryComponent.cpp

bool UInventoryComponent::IsRoomAvailable(AItemBase* ItemToAdd, int32 TopLeftIndex)
{
    FIntPoint Dimensions = ItemToAdd->GetDimensions();
    FIntPoint Tile = IndexToTile(TopLeftIndex);   // 1D → 2D 변환

    // 아이템이 점유할 모든 타일 순회
    for (int32 i = Tile.X; i <= Tile.X + Dimensions.X - 1; i++) {
        for (int32 j = Tile.Y; j <= Tile.Y + Dimensions.Y - 1; j++) {

            if (IsTileValid(FIntPoint(i, j))) {          // ① 경계 안인지
                int32 Index = TileToIndex(FIntPoint(i, j));

                if (GetResultAtIndex(Index)) {            // ② 유효한 인덱스인지
                    if (GetItemAtIndex(Index)) {          // ③ 이미 아이템이 있는지
                        return false;                    // 점유됨 → 배치 불가
                    }
                }
                else { return false; }
            }
            else { return false; }                       // 경계 밖 → 배치 불가
        }
    }
    return true;   // 모든 타일 통과 → 배치 가능
}

 

// 보조 함수들

// 타일이 인벤토리 경계 내에 있는지 확인
bool UInventoryComponent::IsTileValid(FIntPoint Tile)
{
    return (Tile.X >= 0 && Tile.Y >= 0 && Tile.X < Columns && Tile.Y < Rows);
}

// 1D 인덱스가 배열 범위 내인지 확인
bool UInventoryComponent::GetResultAtIndex(int32 Index)
{
    return Items.IsValidIndex(Index);
}

// 해당 인덱스의 아이템 포인터 반환 (없으면 nullptr)
AItemBase* UInventoryComponent::GetItemAtIndex(int32 Index)
{
    if (Items.IsValidIndex(Index)) { return Items[Index]; }
    else { return nullptr; }
}

 

 

 3단계 검증 구조

  • ① IsTileValid — 인벤토리 경계 밖으로 아이템이 삐져나가는지 체크 (X, Y 모두 0 이상 & Columns/Rows 미만)
  • ② GetResultAtIndex — IsValidIndex()로 배열 접근 전 안전성 보장 (out-of-bounds 방지)
  • ③ GetItemAtIndex — 해당 타일이 nullptr이면 비어 있음, 포인터가 있으면 점유 중

6. 아이템 추가 — TryAddItem & AddItemAt

TryAddItem이 배열 전체를 순회하며 첫 번째로 가용한 공간을 찾고, AddItemAt이 해당 위치에 아이템 포인터를 실제로 등록합니다.

 

// InventoryComponent.cpp — TryAddItem

bool UInventoryComponent::TryAddItem(AItemBase* ItemToAdd)
{
    if (ItemToAdd)
    {
        for (int32 i = 0; i < Items.Num(); i++) {
            if (IsRoomAvailable(ItemToAdd, i)) {   // 공간 확인
                AddItemAt(ItemToAdd, i);            // 배열에 등록
                return true;
            }
        }
        return false;   // 공간 없음
    }
    return false;
}

 

// 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;
        }
    }
}

 

 같은 포인터를 여러 타일에 등록하는 이유

  • AK-47(4×2)를 배치하면 점유하는 8칸 모두에 같은 포인터를 저장
  • 이후 어떤 타일을 클릭해도 동일한 아이템 참조를 얻을 수 있어 아이템 이동/제거 시 편리
  • 아이템 제거 시에는 역으로 모든 관련 타일을 nullptr로 초기화하면 됨

7. OnBeginOverlap에 백엔드 연결 & Destroy()

Part 3에서 디버그 메시지만 출력하던 OnBeginOverlap에 실제 인벤토리 추가 로직을 연결하고, 성공 시 월드의 아이템 액터를 Destroy()해 시각적으로 획득을 표현합니다.

 

// IntentorySystemCppCharacter.cpp — Part 3 → Part 4 변경점

void AIntentorySystemCppCharacter::OnBeginOverlap( ... )
{
    AItemBase* Item = Cast<AItemBase>(OtherActor);

    if (Item)
    {
        // Part 3: GEngine->AddOnScreenDebugMessage(...)
        // Part 4: TryAddItem으로 교체 + 성공 시 Destroy
        if (InventoryComponent->TryAddItem(Item))
        {
            OtherActor->Destroy();   // 인벤토리에 추가됐으면 월드에서 제거
        }
        // false 반환 시 = 공간 부족 → 아이템 그대로 유지
    }
}

 

 Destroy() 타이밍

  • TryAddItem이 true를 반환한 경우에만 Destroy() 호출 → 인벤토리가 꽉 찼을 때 아이템이 바닥에 그대로 남음
  • 아직 AddItemAt에서 Destroy()를 직접 호출하지 않고, 호출부(OnBeginOverlap)에서 처리 → 책임 분리 유지

8. 개발 중 직면한 문제 & 해결

① Items 배열 크기가 0인 상태에서 접근 → 크래시

 

🔴 문제
InventoryComponent::BeginPlay에서 배열을 초기화하려 했으나, Columns와 Rows 값이 에디터에서 설정되기 전에 BeginPlay가 호출되어 크기가 0으로 초기화되는 문제 발생.


✅ 해결
배열 초기화를 InventoryComponent::BeginPlay가 아닌 캐릭터의 BeginPlay에서 처리. InventoryComponent->Items.Init(nullptr, InventoryComponent->Columns * InventoryComponent->Rows) 로 에디터 설정값이 확정된 시점에 배열 크기를 할당.

 


9. 오늘 배운 점 & 회고

※ 기술적 인사이트
  • 1D 배열로 2D 그리드 표현 — IndexToTile / TileToIndex 두 함수가 전체 공간 로직의 수학적 기반
  • FIntPoint는 정수형 2D 좌표가 필요할 때 FVector2D보다 타일 인덱스 계산에 적합
  • 아이템 크기만큼 같은 포인터를 여러 타일에 등록하면 어느 타일을 참조해도 동일한 아이템에 접근 가능
  • 배열 초기화 타이밍은 값이 확정된 시점(캐릭터 BeginPlay)을 잘 따져야 함

 

※ 핵심 교훈

"공간 인벤토리의 핵심은 1D 배열을 2D처럼 다루는 좌표 변환이다. Index % Columns와 Index / Columns 공식만 잘 이해하면 경계 체크, 공간 확인, 아이템 등록 모두 자연스럽게 따라온다. 그리고 배열 접근 전에는 항상 IsValidIndex로 안전을 확보하자."