1. 오늘의 개발 개요
인벤토리 시스템의 핵심 UX인 드래그 앤 드롭(Drag & Drop) 기능을 완성했습니다. 아이템을 마우스로 집어서 인벤토리 바깥에 드롭하면 월드에 스폰되고, 다시 캐릭터가 위를 지나가면 줍는 전체 사이클이 동작합니다.
UE5의 Widget 입력 이벤트 시스템(FReply, DragDropOperation, Payload)을 처음으로 직접 다뤘고, 구현 과정에서 발생한 버그 5가지를 수정했습니다.
2. 개발 환경
| 엔진 | Unreal Engine 5.5 |
| 언어 | C++ |
| IDE | Visual Studio 2022 |
| 주요 수정 파일 | InventoryWidget, ItemWidget, InventoryComponent, IB_AK47/Knife/Grenade |
3. 드래그 중 카메라 회전 문제 수정
인벤토리를 열고 아이템을 드래그하면 캐릭터 카메라가 같이 돌아가는 문제가 있었습니다. 마우스 클릭 입력이 위젯에서 처리되지 않고 게임 카메라 시스템으로 그대로 전달됐기 때문입니다.
// InventoryWidget.h
virtual FReply NativeOnMouseButtonDown(
const FGeometry& InGeometry,
const FPointerEvent& InMouseEvent) override;
// InventoryWidget.cpp
FReply UInventoryWidget::NativeOnMouseButtonDown(
const FGeometry& InGeometry,
const FPointerEvent& InMouseEvent)
{
return FReply::Handled(); // 입력 소비 → 카메라로 미전달
}
| ※ FReply::Handled() vs Unhandled() Handled()는 '이 입력은 내가 처리했으니 상위로 전파하지 마라'는 신호다. Unhandled()는 전파를 허용한다. 위젯이 Handled를 반환하면 언리얼 입력 파이프라인이 카메라 컨트롤러까지 이벤트를 넘기지 않는다. |
4. 아이템 호버 하이라이트 효과
마우스를 아이템 위에 올렸을 때 Border의 Alpha(불투명도)를 0→1로 올려 시각적 피드백을 줍니다.
// ItemWidget.cpp
void UItemWidget::NativeConstruct()
{
// 초기 Alpha = 0 (호버 전 투명 상태 보장)
FLinearColor InitialColor = BackgroundBorder->GetBrushColor();
InitialColor.A = 0.0f;
BackgroundBorder->SetBrushColor(InitialColor);
...
}
void UItemWidget::NativeOnMouseEnter(const FGeometry& InGeometry, ...)
{
FLinearColor HoverColor = BackgroundBorder->GetBrushColor();
HoverColor.A = 1.0f;
BackgroundBorder->SetBrushColor(HoverColor);
}
void UItemWidget::NativeOnMouseLeave(const FPointerEvent& InMouseEvent)
{
FLinearColor NormalColor = BackgroundBorder->GetBrushColor();
NormalColor.A = 0.0f;
BackgroundBorder->SetBrushColor(NormalColor);
}
| ※ 초기화를 NativeConstruct에서 해야 하는 이유 Blueprint에서 설정한 Alpha값이 그대로 남아 있으면 위젯 생성 시 테두리가 보인다. 코드에서 Alpha = 0.0f 를 강제 초기화해 Blueprint 설정값과 무관하게 항상 투명하게 시작하도록 보장한다. |
5. 드래그 앤 드롭 구현
UE5 위젯의 D&D는 두 단계로 나뉩니다.
① 마우스를 눌렀을 때 드래그 감지를 등록하고,
② 실제로 드래그가 시작됐을 때 Operation 객체를 만들어 데이터를 실어 보냅니다.
Step 1 — NativeOnMouseButtonDown : 드래그 감지 등록
FReply UItemWidget::NativeOnMouseButtonDown(
const FGeometry& InGeometry,
const FPointerEvent& InMouseEvent)
{
if (InMouseEvent.GetEffectingButton() == EKeys::LeftMouseButton)
{
// 일정 거리 이상 이동 시 NativeOnDragDetected 자동 호출
return FReply::Handled().DetectDrag(TakeWidget(), EKeys::LeftMouseButton);
}
return FReply::Unhandled();
}
Step 2 — NativeOnDragDetected : Operation 생성 & Payload 설정
void UItemWidget::NativeOnDragDetected(
const FGeometry& InGeometry,
const FPointerEvent& InMouseEvent,
UDragDropOperation*& OutOperation)
{
UDragDropOperation* DragOp =
NewObject<UDragDropOperation>(UDragDropOperation::StaticClass());
DragOp->Payload = RepresentedItem; // 전달할 아이템 데이터
DragOp->DefaultDragVisual = this; // 드래그 비주얼 = 현재 위젯
OutOperation = DragOp;
// 드래그 시작과 동시에 인벤토리 배열에서 제거
CharacterReference->InventoryComponent->RemoveItem(RepresentedItem);
RemoveFromParent(); // 화면에서 위젯 제거
}
| ※Payload란? UDragDropOperation의 Payload는 UObject* 타입으로 드래그 데이터를 담는 슬롯이다. 드롭 지점(NativeOnDrop)에서 Cast(InOperation->Payload)로 꺼내 사용한다. 어떤 UObject든 담을 수 있어 범용적인 D&D 데이터 전달 방식으로 활용된다. |
6. 인벤토리에서 아이템 제거 (RemoveItem)
드래그가 시작되면 인벤토리 배열(Items TArray)에서 해당 슬롯을 nullptr로 비웁니다.
// InventoryComponent.cpp
void UInventoryComponent::RemoveItem(AItemBase* ItemToRemove)
{
for (int32 i = 0; i < Items.Num(); i++)
{
if (Items[i] == ItemToRemove)
{
Items[i] = nullptr;
}
}
}
| ※ TArray::Remove()가 아닌 nullptr 교체를 쓰는 이유 이 인벤토리는 인덱스 = 타일 위치인 격자 구조다. Remove()를 사용하면 뒤 요소가 앞으로 당겨져 인덱스-타일 매핑이 깨진다. 슬롯을 nullptr로만 교체해 배열 크기(Rows*Columns)와 인덱스 구조를 유지한다. |
7. 바닥에 아이템 드롭 (NativeOnDrop)
InventoryWidget에 NativeOnDrop을 구현해 드롭 시 월드에 아이템을 스폰합니다. 단순히 캐릭터 위치로 스폰하면 공중에 뜨기 때문에 LineTrace로 실제 바닥 좌표를 구합니다.
bool UInventoryWidget::NativeOnDrop(
const FGeometry& InGeometry,
const FDragDropEvent& InDragDropEvent,
UDragDropOperation* InOperation)
{
AItemBase* DroppedItem = Cast<AItemBase>(InOperation->Payload);
if (!DroppedItem) return false;
// 선언 먼저, 사용은 그 다음 (역순 작성 시 컴파일 에러)
FHitResult HitResult;
FCollisionQueryParams Params;
Params.AddIgnoredActor(Character);
FVector TraceStart = Character->GetActorLocation()
+ Character->GetActorForwardVector() * 100.f;
FVector TraceEnd = TraceStart + FVector(0.f, 0.f, -1000.f);
bool bHit = GetWorld()->LineTraceSingleByChannel(
HitResult, TraceStart, TraceEnd, ECC_Visibility, Params);
FVector SpawnLocation = bHit ? HitResult.ImpactPoint : TraceStart;
GetWorld()->SpawnActor<AItemBase>(
DroppedItem->GetClass(), SpawnLocation, SpawnRotation, SpawnParams);
return true;
}
| ※ LineTrace 바닥 스폰 흐름 캐릭터 앞 100 지점에서 아래로 1000 거리를 쐈을 때 충돌한 지점(ImpactPoint)을 SpawnLocation으로 사용한다. 레이가 아무것도 맞추지 못하면 TraceStart를 대신 사용해 스폰이 실패하지 않도록 폴백을 둔다. |
8. 아이템 기본값 설정 (ConstructorHelpers)
드롭 후 재스폰 시 아이콘이 깨지는 버그를 막기 위해 각 아이템 클래스 생성자에서 Dimensions와 Icon을 직접 설정합니다.
// IB_AK47.cpp
AIB_AK47::AIB_AK47()
{
Dimensions = FIntPoint(2, 4); // 가로 2칸, 세로 4칸
static ConstructorHelpers::FObjectFinder<UMaterialInstance> IconFinder(
TEXT("/Game/InventoryResources/MaterialIcons/MI_AK47"));
if (IconFinder.Succeeded())
{
Icon = IconFinder.Object;
}
}
| ※ ConstructorHelpers::FObjectFinder 경로 규칙 Content 폴더 기준으로 /Game/ 이 루트다. 에디터에서 에셋 우클릭 → Copy Reference 하면 정확한 경로를 얻을 수 있다. 경로가 틀리면 빌드는 되지만 런타임에 Icon = nullptr가 되어 체크 패턴으로 표시된다. |
9. 개발 중 직면한 문제 & 해결
| 버그 현상 | 원인 | 해결 |
| 드래그 중 카메라가 회전함 | 마우스 입력이 위젯에서 소비되지 않고 카메라 시스템으로 전달됨 | InventoryWidget에 NativeOnMouseButtonDown 오버라이드 → FReply::Handled() 반환 |
| 아이템 드롭 후 공중에 뜸 | SpawnLocation을 캐릭터 위치 Z값 그대로 사용 (바닥 높이 무시) | LineTraceSingleByChannel로 아래쪽 레이캐스트 → ImpactPoint를 SpawnLocation으로 사용 |
| 칼·수류탄 아이콘 재픽업 시 깨짐 | FObjectFinder 경로가 실제 에셋 경로와 불일치 → Icon = nullptr로 스폰 | Content Browser → Copy Reference로 정확한 경로 확인 후 수정 |
| 컴파일 에러 - undeclared identifier | HitResult, Params를 사용한 후에 선언 (역순 작성) | FHitResult, FCollisionQueryParams 선언을 LineTrace 호출 앞으로 이동 |
| 호버 전 Border가 보임 | NativeConstruct에서 초기 Alpha 값을 설정하지 않아 BP 기본값 그대로 노출 | NativeConstruct에서 BackgroundBorder->SetBrushColor() Alpha = 0.0f 강제 초기화 |
10. 오늘 배운 점 & 회고
기술적 인사이트
- FReply::Handled() — 위젯 입력 이벤트를 소비해 게임 시스템으로의 전파를 차단하는 핵심 반환값
- DetectDrag() — 버튼 다운 시점에 드래그 감지를 등록, 일정 거리 이상 이동하면 NativeOnDragDetected 자동 호출
- UDragDropOperation::Payload — UObject* 슬롯 하나로 어떤 데이터든 드롭 지점까지 전달 가능
- LineTraceSingleByChannel — 레이캐스트로 실제 바닥 좌표를 구해 정확한 위치에 스폰
- ConstructorHelpers::FObjectFinder — 생성자에서 에셋을 로드해 재스폰 후에도 데이터 유지
- nullptr 슬롯 교체 — 인덱스 기반 격자 구조를 유지하면서 아이템만 제거하는 방식
핵심 교훈
| "드래그 앤 드롭은 입력 이벤트 파이프라인의 흐름을 이해하는 것이 핵심이다. FReply로 입력을 어디서 소비할지 결정하고, DetectDrag → NativeOnDragDetected → Payload → NativeOnDrop의 순서를 지켜야 데이터가 드롭 지점까지 안전하게 전달된다." |
'Unreal' 카테고리의 다른 글
| C++을 사용한 언리얼엔진 인벤토리 시스템 Final (0) | 2026.03.24 |
|---|---|
| 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 |