이전 글에서는 상호작용 대상을 Gameplay Tag로 구분해 핫바에 저장하거나 Physics Handle로 운반하는 시스템을 구현했다.
하지만 실제 기획을 구체화하면서 벽 조각이나 샘플 같은 자원은 인벤토리 데이터로 즉시 변환되는 것이 아니라, 월드에 존재하는 물리 오브젝트 상태로 쓰레기통에 담기거나, 직접 손으로 들거나 선택해야 한다.
쓰레기통은 물리 시뮬레이션을 사용하는 하나의 액터로 만들었다.
주요 컴포넌트는 다음과 같다.
Mesh: 쓰레기통 본체와 물리 충돌 담당GrabPoint: Physics Handle이 쓰레기통을 잡을 위치ContentsVolume: 쓰레기통 내부로 판정할 오버랩 영역AMD_TrashBin::AMD_TrashBin()
{
PrimaryActorTick.bCanEverTick = false;
bReplicates = true;
SetReplicateMovement(true);
Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
SetRootComponent(Mesh);
Mesh->SetCollisionProfileName(TEXT("PhysicsActor"));
Mesh->SetCollisionObjectType(ECC_PhysicsBody);
Mesh->SetCollisionResponseToChannel(MDCollisionChannels::InteractTrace, ECR_Block);
Mesh->SetSimulatePhysics(true);
GrabPoint = CreateDefaultSubobject<USceneComponent>(TEXT("GrabPoint"));
GrabPoint->SetupAttachment(Mesh);
GrabPoint->SetRelativeLocation(FVector(0.f, 0.f, 80.f));
ContentsVolume = CreateDefaultSubobject<UBoxComponent>(TEXT("ContentsVolume"));
ContentsVolume->SetupAttachment(Mesh);
}
ContentsVolume은 Mesh에 부착했기 때문에 쓰레기통이 이동하거나 회전하면 내부 판정 영역도 함께 움직인다.
내용물은 쓰레기통에 직접 부착하지 않았다. 통 안에 들어온 뒤에도 계속 독립적인 물리 오브젝트로 존재하기 때문에 충돌하거나 흔들리고, 쓰레기통을 기울이면 밖으로 빠져나올 수 있다.

쓰레기통 내부 판정은 물리 충돌과 분리된 QueryOnly 볼륨을 사용했다.
ContentsVolume->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
ContentsVolume->SetCollisionObjectType(ECC_WorldDynamic);
ContentsVolume->SetCollisionResponseToAllChannels(ECR_Ignore);
ContentsVolume->SetCollisionResponseToChannel(ECC_PhysicsBody, ECR_Overlap);
ContentsVolume->SetGenerateOverlapEvents(true);
ContentsVolume->SetCanEverAffectNavigation(false);
모든 채널을 무시한 뒤 PhysicsBody만 오버랩하도록 설정했다.
이 볼륨은 물체를 막거나 밀어내지 않고, 쓰레기통 본체의 충돌은 Mesh가 담당하며, ContentsVolume은 현재 어떤 물체가 통 안에 있는지만 감지한다.
다만 PhysicsBody라는 이유만으로 모두 쓰레기로 인정하면 플레이어가 쓰레기통이나 다른 물리 장비를 안에 넣었을 때도 내용물로 집계되므로, 물리 채널은 검출 대상을 좁히는 용도로만 사용하고, 실제 내용물 분류에는 Gameplay Tag를 사용했다.
쓰레기통에 담을 수 있는 물체를 공통 루트 태그 아래에 다음과 같이 정의했다.
Resource.Storage.Content
├── WallPiece
├── Contamination
└── Sample
벽 조각과 샘플은 기존의 픽업 방식 태그와 별도로 자신이 어떤 내용물인지 나타내는 태그를 가진다.
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "MD|Resource")
FGameplayTag StorageContentTag;
IGameplayTagAssetInterface를 통해 두 종류의 태그를 함께 반환한다.
void AMD_WallPiece::GetOwnedGameplayTags(FGameplayTagContainer& TagContainer) const
{
TagContainer.Reset();
if (PickupMethodTag.IsValid() && PickupMethodTag.MatchesTag(
MDGameplayTags::Interaction_Pickup_Physics))
{
TagContainer.AddTag(PickupMethodTag);
}
if (StorageContentTag.IsValid() && StorageContentTag.MatchesTag(
MDGameplayTags::Resource_Storage_Content))
{
TagContainer.AddTag(StorageContentTag);
}
}
두 태그의 역할은 서로 다르다.
Interaction.Pickup.Physics는 플레이어가 이 물체를 어떻게 획득하는지 결정Resource.Storage.Content.*는 쓰레기통이 이 물체를 내용물로 인정할지 결정획득 방식과 보관 분류를 분리했기 때문에 물리적으로 들 수는 있지만 쓰레기통에는 내용물로 인정되지 않는 액터도 표현할 수 있다.
쓰레기통은 자신이 허용할 내용물 태그 목록을 가진다.
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "MD|Trash Bin")
FGameplayTagContainer AcceptedContentTags;
Blueprint에서 Resource.Storage.Content 루트 태그를 지정하면 모든 하위 내용물을 허용할 수 있다.
특정 쓰레기통에 Resource.Storage.Content.WallPiece만 지정하면 벽 조각 전용 보관함으로도 사용할 수 있다.

오버랩된 액터가 실제로 허용된 내용물인지는 다음 함수에서 검사한다.
bool AMD_TrashBin::IsAcceptedContent(const AActor* Actor) const
{
if (!IsValid(Actor) || AcceptedContentTags.IsEmpty())
{
return false;
}
const IGameplayTagAssetInterface* TagAsset = Cast<IGameplayTagAssetInterface>(Actor);
if (!TagAsset)
{
return false;
}
FGameplayTagContainer ActorTags;
TagAsset->GetOwnedGameplayTags(ActorTags);
for (const FGameplayTag& AcceptedTag : AcceptedContentTags)
{
if (AcceptedTag.MatchesTag(MDGameplayTags::Resource_Storage_Content)
&& ActorTags.HasTag(AcceptedTag))
{
return true;
}
}
return false;
}
에디터의 Categories 메타데이터로 선택할 수 있는 태그의 범위를 제한했지만, 런타임에서도 허용 태그가 실제 Resource.Storage.Content 계층에 속하는지 다시 확인했다.
이를 통해 잘못된 태그가 Blueprint에 지정되더라도 전혀 관계없는 액터가 내용물로 처리되는 것을 방지했다.

멀티플레이 환경에서 각 클라이언트가 개별적으로 오버랩을 계산하면 같은 쓰레기통의 내용물 개수가 서로 다르게 보일 수 있다.
따라서 오버랩 이벤트 연결과 내용물 판정은 서버에서만 수행했다.
void AMD_TrashBin::BeginPlay()
{
Super::BeginPlay();
if (!HasAuthority())
{
ContentsVolume->SetGenerateOverlapEvents(false);
return;
}
ContentsVolume->OnComponentBeginOverlap.AddDynamic(this, AMD_TrashBin::HandleBeginOverlap);
ContentsVolume->OnComponentEndOverlap.AddDynamic(this, &AMD_TrashBin::HandleEndOverlap);
RefreshContents();
}
클라이언트에서는 ContentsVolume의 오버랩 이벤트 생성을 비활성화한다.
서버는 BeginPlay 시점에 이벤트를 연결하고, RefreshContents()를 한 번 호출한다. 덕분에 BeginPlay 이전부터 볼륨과 겹쳐 있던 액터도 초기 내용물에 포함된다.
가장 단순한 구현은 BeginOverlap에서 개수를 증가시키고 EndOverlap에서 감소시키는 것이다.
void HandleBeginOverlap()
{
++ContentCount;
}
void HandleEndOverlap()
{
--ContentCount;
}
하지만 오버랩 이벤트는 액터가 아니라 컴포넌트를 기준으로 발생할 수 있다.
하나의 액터가 여러 충돌 컴포넌트를 가지고 있다면 BeginOverlap이 여러 번 호출될 수 있고, 물리 오브젝트가 볼륨 경계에서 흔들리면 진입과 이탈 이벤트가 짧은 시간 동안 반복될 수도 있다.
이벤트의 호출 횟수를 그대로 개수로 사용하면 실제로 통 안에 있는 액터 수와 ContentCount가 어긋날 수 있다.
그래서 BeginOverlap과 EndOverlap은 개수를 직접 변경하지 않고, 현재 볼륨과 겹친 전체 액터 집합을 다시 확인하는 신호로만 사용했다.
void AMD_TrashBin::HandleBeginOverlap(
UPrimitiveComponent* OverlappedComponent,
AActor* OtherActor,
UPrimitiveComponent* OtherComponent,
int32 OtherBodyIndex,
bool bFromSweep,
const FHitResult& SweepResult)
{
if (!HasAuthority()) return;
RefreshContents();
}
void AMD_TrashBin::HandleEndOverlap(
UPrimitiveComponent* OverlappedComponent,
AActor* OtherActor,
UPrimitiveComponent* OtherComponent,
int32 OtherBodyIndex)
{
if (!HasAuthority()) return;
RefreshContents();
}
현재 내용물은 액터를 중복 없이 저장할 수 있는 TSet으로 관리했다.
TSet<TWeakObjectPtr<AActor>> Contents;
강한 포인터 대신 TWeakObjectPtr을 사용했기 때문에 쓰레기통의 추적 정보가 액터의 수명을 불필요하게 연장하지 않는다.
RefreshContents()는 현재 볼륨과 겹친 액터를 다시 가져와 실제 내용물 집합을 만든다.
void AMD_TrashBin::RefreshContents()
{
if (!HasAuthority() || !IsValid(ContentsVolume)) return;
TSet<AActor*> OverlappingActors;
ContentsVolume->GetOverlappingActors(OverlappingActors);
TSet<TWeakObjectPtr<AActor>> ActualContents;
ActualContents.Reserve(OverlappingActors.Num());
for (AActor* OverlappingActor : OverlappingActors)
{
if (IsAcceptedContent(OverlappingActor))
{
ActualContents.Add(TWeakObjectPtr<AActor>(OverlappingActor));
}
}
이후 기존 Contents에는 있지만 현재 오버랩에는 없는 액터를 제거한다.
for (auto ContentIt = Contents.CreateIterator(); ContentIt; ++ContentIt)
{
AActor* Content = ContentIt->Get();
if (IsValid(Content) && ActualContents.Contains(*ContentIt))
{
continue;
}
if (IsValid(Content))
{
Content->OnDestroyed.RemoveDynamic(this, &AMD_TrashBin::HandleContentDestroyed);
}
ContentIt.RemoveCurrent();
}
반대로 새롭게 들어온 액터는 Contents에 추가한다.
for (const TWeakObjectPtr<AActor>& ContentPtr : ActualContents)
{
if (Contents.Contains(ContentPtr))
{
continue;
}
if (AActor* Content = ContentPtr.Get())
{
Contents.Add(ContentPtr);
Content->OnDestroyed.AddUniqueDynamic(this, &AMD_TrashBin::HandleContentDestroyed);
}
}
SyncContentCount();
}
이 구조에서는 오버랩 이벤트가 여러 번 발생해도 최종적으로 현재 볼륨 안에 존재하는 유효한 액터 집합만 남는다.
매 프레임 검사하지 않고 오버랩 상태가 변했을 때만 전체 집합을 갱신하기 때문에 쓰레기통 액터의 Tick도 필요하지 않다.
