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 교환으로 회전 구현
|
3. 드래그 감지 — ItemWidget
인벤토리 칸에 표시된 아이템 위젯에서 마우스 버튼을 누르면 드래그가 감지되고, DragDropOperation의 Payload에 아이템 포인터를 담아 전달합니다. 드래그 시작 시 기존 인벤토리 배열에서 해당 아이템을 즉시 제거해 이중 점유를 방지합니다.
// 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() 타이밍
|
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 기준점이 필요한 이유
|
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 차이
|
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;
}
|
자동 회전 후 실패 시 반드시 원상 복구
|
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를 갱신하는 패턴 — 즉시 갱신보다 안정적
|
핵심 교훈
"드래그 & 드롭 시스템에서 가장 중요한 원칙은 '드래그 시작 = 배열에서 즉시 제거'다. 그래야 자기 자신과의 충돌 없이 어디서든 공간 확인이 정확해지고, 드롭 실패 시 월드로 되돌리는 안전망도 자연스럽게 구성된다. 실패 케이스를 항상 먼저 설계하자." |
'Unreal' 카테고리의 다른 글
| C++을 사용한 언리얼엔진 인벤토리 시스템 Part.6 (0) | 2026.03.20 |
|---|---|
| C++을 사용한 언리얼엔진 인벤토리 시스템 Part.5 (0) | 2026.03.18 |
| C++을 사용한 언리얼엔진 인벤토리 시스템 Part.4 (0) | 2026.03.13 |
| C++을 사용한 언리얼엔진 인벤토리 시스템 Part.3 (0) | 2026.03.12 |
| C++을 사용한 언리얼엔진 인벤토리 시스템 Part.2 (0) | 2026.03.10 |