Unreal

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

story98138 2026. 3. 24. 15:58

1. 오늘의 개발 개요

공간 인벤토리 시스템의 마지막 파트입니다. Part 4에서 완성한 백엔드 배열 로직 위에 사용자가 직접 조작할 수 있는 드래그 & 드롭, 아이템 회전(R키), 드롭 위치 가이드 시각화(초록/빨강 박스)를 구현하고 아이템 중첩 버그를 수정하며 시스템을 완성했습니다.

 

 


2. 아이템 회전 — ItemBase::Rotate()

bIsRotated 플래그로 회전 상태를 추적하고, Rotate()를 호출할 때마다 Dimensions의 X·Y를 서로 교환합니다. GetIcon()은 회전 상태에 따라 일반 아이콘과 회전 아이콘을 구분해 반환합니다.

// ItemBase.h — 회전 관련 추가 멤버

bool IsRotated = false;
void Rotate();

UPROPERTY(EditAnywhere, Category = "Item Info | Icon")
UMaterialInstance* Icon;

UPROPERTY(EditAnywhere, Category = "Item Info | Icon")
UMaterialInstance* RotatedIcon;   // 회전 시 표시할 별도 아이콘

 

// ItemBase.cpp

UMaterialInstance* AItemBase::GetIcon()
{
    if (IsRotated && RotatedIcon)
        return RotatedIcon;
    return Icon;
}

void AItemBase::Rotate()
{
    IsRotated = !IsRotated;
    // Dimensions X↔Y 교환 → 4×2가 2×4로
    Dimensions = FIntPoint(Dimensions.Y, Dimensions.X);
}

 Dimensions 교환으로 회전 구현
  • AK-47(4×2) → Rotate() 호출 → (2×4)로 변경됨. 공간 확인 로직이 변경된 Dimensions를 그대로 사용하므로 추가 수정 없이 회전이 반영됨.
  • 아이콘도 회전 전용 RotatedIcon을 별도 설정하면 GetIcon()이 자동으로 올바른 이미지를 반환.

 


3. 드래그 감지 — ItemWidget

인벤토리 칸에 표시된 아이템 위젯에서 마우스 버튼을 누르면 드래그가 감지되고, DragDropOperationPayload에 아이템 포인터를 담아 전달합니다. 드래그 시작 시 기존 인벤토리 배열에서 해당 아이템을 즉시 제거해 이중 점유를 방지합니다.

 

// ItemWidget.cpp — NativeOnMouseButtonDown / NativeOnDragDetected

// 마우스 버튼 다운 시 드래그 감지 시작 요청
FReply UItemWidget::NativeOnMouseButtonDown(..., const FPointerEvent& InMouseEvent)
{
    if (InMouseEvent.GetEffectingButton() == EKeys::LeftMouseButton)
        return FReply::Handled().DetectDrag(TakeWidget(), EKeys::LeftMouseButton);
    return FReply::Unhandled();
}

// 드래그 확정 → DragDropOperation 생성
void UItemWidget::NativeOnDragDetected(..., UDragDropOperation*& OutOperation)
{
    UDragDropOperation* DragOp = NewObject<UDragDropOperation>(...);
    DragOp->Payload          = RepresentedItem;     // 아이템 포인터를 페이로드로
    DragOp->DefaultDragVisual = this;              // 드래그 중 표시할 비주얼
    OutOperation              = DragOp;

    CharacterReference->DraggingItem       = RepresentedItem;
    CharacterReference->DraggingItemWidget = this;

    // 배열에서 즉시 제거 → 이중 점유 방지
    CharacterReference->InventoryComponent->RemoveItem(RepresentedItem);
    RemoveFromParent();
}

 

 
드래그 시작 시 RemoveItem() 타이밍
  • 드래그 시작과 동시에 배열에서 제거해야 IsRoomAvailableForPayload가 자기 자신을 점유로 잘못 판정하는 버그를 막을 수 있음.
  • 드롭 실패 시 원래 위치 복구 또는 캐릭터 앞에 스폰하는 로직으로 연결됨.

 


4. 마우스 타일 추적 — NativeOnDragOver

드래그 중 마우스가 그리드 위를 지나갈 때마다 호출됩니다. 화면 좌표 → 위젯 로컬 좌표 → 그리드 기준 상대 좌표로 변환한 뒤, 아이템 크기의 절반만큼 오프셋을 적용해 아이템 중심이 마우스에 오도록 타일을 계산합니다.

 

// InventoryGridWidget.cpp — NativeOnDragOver

bool UInventoryGridWidget::NativeOnDragOver(
    const FGeometry& InGeometry,
    const FDragDropEvent& InDragDropEvent,
    UDragDropOperation* InOperation)
{
    AItemBase* Payload = Cast<AItemBase>(InOperation->Payload);
    if (!Payload) return false;
    DraggingItem = Payload;

    // ① 화면 좌표 → 위젯 로컬 좌표
    FVector2D MouseScreenPos = InDragDropEvent.GetScreenSpacePosition();
    FVector2D LocalPos        = InGeometry.AbsoluteToLocal(MouseScreenPos);

    // ② GridBorder의 실제 (0,0) 기준점
    FVector2D GridOrigin  = GridBorder->GetCachedGeometry()
                               .GetLocalPositionAtCoordinates(FVector2D(0.0f, 0.0f));
    FVector2D RelativePos = LocalPos - GridOrigin;

    // ③ 아이템 중심 기준 오프셋 적용
    FIntPoint Dims    = Payload->GetDimensions();
    float     OffsetX = FMath::FloorToInt(Dims.X * 0.5f) * TileSize;
    float     OffsetY = FMath::FloorToInt(Dims.Y * 0.5f) * TileSize;

    int32 TileX = FMath::FloorToInt((RelativePos.X - OffsetX) / TileSize);
    int32 TileY = FMath::FloorToInt((RelativePos.Y - OffsetY) / TileSize);
    CurrentDragTile = FIntPoint(TileX, TileY);

    // ④ 해당 위치에 공간이 있는지 미리 확인 → 색상 결정용
    int32 TargetIndex = InventoryComponent->TileToIndex(CurrentDragTile);
    bCanDrop = InventoryComponent->IsRoomAvailableForPayload(Payload, TargetIndex, Payload);

    Invalidate(EInvalidateWidgetReason::Paint);
    return true;
}

 

 
GridBorder 기준점이 필요한 이유
  • 위젯 로컬 좌표의 원점(0,0)은 위젯 전체의 좌상단. 그리드 테두리(Border)는 여백 때문에 실제로 더 안쪽에 위치함.
  • GetCachedGeometry().GetLocalPositionAtCoordinates(FVector2D(0,0))로 Border의 실제 좌상단을 구해 빼줘야 정확한 타일 계산이 가능.

 


5. 드롭 확정 — NativeOnDrop

마우스를 놓는 순간 호출됩니다. IsRoomAvailableForPayload로 목표 타일에 공간이 있으면 배치하고, 없으면 다른 빈 공간을 탐색합니다. 그마저도 없으면 캐릭터 앞 월드 좌표로 아이템을 되돌려 보냅니다.

 

// InventoryGridWidget.cpp — NativeOnDrop

bool UInventoryGridWidget::NativeOnDrop(...)
{
    AItemBase* Payload    = Cast<AItemBase>(InOperation->Payload);
    int32      TargetIndex = InventoryComponent->TileToIndex(CurrentDragTile);

    if (InventoryComponent->IsRoomAvailableForPayload(Payload, TargetIndex, Payload))
    {
        // 목표 위치 배치 성공
        InventoryComponent->RefreshAllItems();
        InventoryComponent->AddItemAt(Payload, TargetIndex);
    }
    else
    {
        // 전체 탐색으로 대체 위치 검색
        bool bPlaced = false;
        for (int32 i = 0; i < InventoryComponent->Items.Num(); i++)
        {
            if (InventoryComponent->IsRoomAvailableForPayload(Payload, i, Payload))
            {
                InventoryComponent->RefreshAllItems();
                InventoryComponent->AddItemAt(Payload, i);
                bPlaced = true;
                break;
            }
        }

        // 인벤토리 꽉 참 → 캐릭터 앞에 드롭
        if (!bPlaced && CharacterReference)
        {
        
        
        
            FVector DropLocation = CharacterReference->GetActorLocation()
                + CharacterReference->GetActorForwardVector() * 150.0f;
            Payload->SetActorLocation(DropLocation);
        }
    }

    // 드래그 상태 초기화
    DraggingItem    = nullptr;
    CurrentDragTile = FIntPoint(-1, -1);
    bCanDrop        = false;
    CharacterReference->DraggingItem = nullptr;
    Invalidate(EInvalidateWidgetReason::Paint);
    return true;
}

 


IsRoomAvailableForPayload vs IsRoomAvailable 차이
  • IsRoomAvailable (Part 4) — 해당 타일에 어떤 아이템이든 있으면 false
  • IsRoomAvailableForPayload (Part 7 신규) — ItemToExclude와 동일한 포인터는 점유로 보지 않음 → 드래그 중인 자기 자신을 무시하고 공간 판정
  • 이 차이가 없으면 아이템을 원래 자리 근처에 놓을 때 자기 자신과 충돌해 배치에 실패하는 버그가 발생

6. 드롭 위치 가이드 시각화 — NativePaint

드래그 중일 때 NativePaint 내에서 FSlateDrawElement::MakeBox로 현재 드롭 예정 위치에 반투명 박스를 그립니다. bCanDrop 값에 따라 초록(배치 가능) / 빨강(배치 불가) 색상을 전환합니다.

 

 

// InventoryGridWidget.cpp — NativePaint 드롭 가이드 박스

if (DraggingItem && CurrentDragTile.X >= 0 && CurrentDragTile.Y >= 0)
{
    FIntPoint Dims = DraggingItem->GetDimensions();
    float BoxX = CurrentDragTile.X * TileSize + TopLeftCorner.X;
    float BoxY = CurrentDragTile.Y * TileSize + TopLeftCorner.Y;
    float BoxW = Dims.X * TileSize;
    float BoxH = Dims.Y * TileSize;

    // bCanDrop에 따라 초록 / 빨강 선택
    FLinearColor BoxColor = bCanDrop
        ? FLinearColor(0.0f, 1.0f, 0.0f, 0.3f)
        : FLinearColor(1.0f, 0.0f, 0.0f, 0.3f);

    FSlateBrush Brush;
    Brush.TintColor = FSlateColor(BoxColor);
    Brush.DrawAs    = ESlateBrushDrawType::Image;

    FSlateDrawElement::MakeBox(
        OutDrawElements,
        LayerId + 1,
        AllottedGeometry.ToPaintGeometry(
            FVector2D(BoxW, BoxH),
            FSlateLayoutTransform(FVector2D(BoxX, BoxY))
        ),
        &Brush,
        ESlateDrawEffect::None,
        BoxColor
    );
}

 


7. R키 회전 & 자동 회전 습득 로직

드래그 중 R키를 누르면 아이템이 회전하고 비주얼이 즉시 갱신됩니다. 또한 바닥에서 아이템을 주울 때 정방향으로 공간이 없으면 자동으로 90도 회전 후 재시도하는 스마트 습득 로직이 TryAddItem에 포함되어 있습니다.

 

// IntentorySystemCppCharacter.cpp — RotateDraggingItem

void AIntentorySystemCppCharacter::RotateDraggingItem()
{
    if (!DraggingItem) return;

    DraggingItem->Rotate();   // Dimensions X↔Y 교환

    if (DraggingItemWidget)
        DraggingItemWidget->Refresh(DraggingItem);   // 비주얼 위젯 즉시 갱신

    if (InventoryComponent && InventoryComponent->InventoryGridWidgetReference)
        InventoryComponent->InventoryGridWidgetReference->RequestRepaint(); // 박스 재드로우
}

 

// InventoryComponent.cpp — TryAddItem 자동 회전 로직

bool UInventoryComponent::TryAddItem(AItemBase* ItemToAdd)
{
    if (!ItemToAdd) return false;

    // ① 정방향으로 먼저 시도
    for (int32 i = 0; i < Items.Num(); i++)
        if (IsRoomAvailable(ItemToAdd, i))
        { AddItemAt(ItemToAdd, i); return true; }

    // ② 공간 없으면 자동 회전 후 재시도
    ItemToAdd->Rotate();
    for (int32 i = 0; i < Items.Num(); i++)
        if (IsRoomAvailable(ItemToAdd, i))
        { AddItemAt(ItemToAdd, i); return true; }

    // ③ 둘 다 실패 → 원래 방향 복구 후 false
    ItemToAdd->Rotate();
    return false;
}
자동 회전 후 실패 시 반드시 원상 복구
  • 자동 회전을 시도했다가 실패했을 때 Rotate()를 한 번 더 호출해 원래 방향으로 되돌려야 함.
  • 복구하지 않으면 다음 습득 시도에서 이미 회전된 상태로 시작돼 치수가 틀어짐.
  • R키 회전도 동일 Rotate()를 사용하므로 짝수 번 호출하면 원래 방향이 됨.

 


8. UI 갱신 — RefreshAllItems & Refresh

아이템 이동 후 AllItems 맵을 완전히 비우고 재빌드하는 방식으로 위치 동기화 문제를 해결합니다. AddItemAt 내에서 AddedItem = true로 플래그를 세우면 TickComponent가 다음 프레임에 Refresh()를 자동 호출합니다.

 

// InventoryComponent.cpp — TickComponent 기반 자동 갱신

void UInventoryComponent::TickComponent(...)
{
    Super::TickComponent(...);

    if (AddedItem)
    {
        if (InventoryGridWidgetReference)
            InventoryGridWidgetReference->Refresh();
        AddedItem = false;
    }
}

 

// InventoryGridWidget.cpp — Refresh : GridCanvasPanel 재빌드

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

    TMap<AItemBase*, FIntPoint> AllItems = InventoryComponent->GetAllItems();

    for (auto& Pair : AllItems)
    {
        AItemBase*  CurrentItem = Pair.Key;
        FIntPoint   TopLeftTile = Pair.Value;
        if (!CurrentItem) continue;

        CharacterReference->ItemToAdd = CurrentItem;

        UUserWidget* NewWidget = CreateWidget(GetWorld(), CharacterReference->ItemWidgetClass);
        NewWidget->SetOwningPlayer(GetOwningPlayer());

        UCanvasPanelSlot* Slot = Cast<UCanvasPanelSlot>(
            GridCanvasPanel->AddChild(NewWidget));

        if (Slot)
        {
            Slot->SetAutoSize(true);
            Slot->SetPosition(FVector2D(
                TopLeftTile.X * InventoryComponent->TileSize,
                TopLeftTile.Y * InventoryComponent->TileSize
            ));
        }
    }
}

 


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

① 드래그 중인 아이템이 자기 자신과 충돌해 배치 실패

🔴 문제 
기존 IsRoomAvailable은 해당 타일에 어떤 포인터든 있으면 false를 반환. 드래그 시작 시 아이템을 배열에서 제거하지 않으면, 원래 자리 근처에 드롭할 때 자기 자신 때문에 배치 불가 판정. 

✅ 해결 
IsRoomAvailableForPayload(ItemToAdd, Index, ItemToExclude)를 신규 작성해 ItemToExclude와 같은 포인터는 점유로 보지 않도록 처리. 드래그 시작 시 RemoveItem()으로 배열에서도 즉시 제거.

 

②  인벤토리 꽉 찼을 때 드롭하면 아이템이 사라지는 버그

🔴 문제
배치 실패 시 아이템 포인터가 어떤 배열에도 없는 상태로 남아 사실상 소실됨.

✅ 해결
NativeOnDrop의 배치 실패 분기에서 Payload->SetActorLocation(캐릭터 앞 150cm)으로 아이템을 월드에 되돌려 놓아 소실을 방지.

 


10. 오늘 배운 점 & 회고

 기술적 인사이트
  • NativeOnDragOver는 실시간 피드백용, NativeOnDrop은 실제 배치 확정용 — 역할을 명확히 분리
  • FSlateDrawElement::MakeBox로 NativePaint 안에서 임의 박스를 직접 렌더링 가능 — UMG 위젯 없이 Slate 레벨에서 그리기
  • 드래그 중 자기 자신을 제외하는 ItemToExclude 패턴은 인벤토리 이동 로직의 핵심
  • TickComponent로 플래그(AddedItem)를 감시해 다음 프레임에 UI를 갱신하는 패턴 — 즉시 갱신보다 안정적
 
핵심 교훈
"드래그 & 드롭 시스템에서 가장 중요한 원칙은 '드래그 시작 = 배열에서 즉시 제거'다. 그래야 자기 자신과의 충돌 없이 어디서든 공간 확인이 정확해지고, 드롭 실패 시 월드로 되돌리는 안전망도 자연스럽게 구성된다. 실패 케이스를 항상 먼저 설계하자."