이전 글에서는 쓰레기와 쓰레기 분류기를 만들었다.
플레이어는 임시 쓰레기통을 들고 현장으로 이동한 뒤, 쓰레기를 담아서 모선으로 가져와 분류기에 비워야 한다.
그런데 쓰레기통을 잃어버리거나 멀리 두고 오면 문제가 생긴다.
그래서 이번에는 기지에서 빈 임시 쓰레기통을 무한정 꺼낼 수 있는 쓰레기통 배출기(AMD_TrashDispenser)를 만들었다.
전체 흐름은 다음과 같다.

배출기는 다음 컴포넌트로 구성했다.

각 컴포넌트의 역할은 다음과 같다.
| 컴포넌트 | 역할 |
|---|---|
Root | 나머지 컴포넌트가 붙는 기준점 |
Mesh | 쓰레기통 배출기 외형 |
InteractionVolume | 플레이어의 상호작용 Trace를 받는 영역 |
SpawnPoint | 쓰레기통이 생성될 위치와 방향 |
SpawnClearance | 배출구가 막혀 있는지 검사하는 상자 |
배출기는 기지에 고정된 시설이기 때문에 물리를 사용하지 않는다.
Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
Mesh->SetupAttachment(Root);
Mesh->SetCollisionProfileName(TEXT("BlockAll"));
Mesh->SetSimulatePhysics(false);
쓰레기통은 플레이어가 밀거나 들 수 있어야 하지만, 배출기까지 물리로 움직이면 플레이어가 부딪힐 때 기계가 넘어지거나 밀려난다.
그래서 배출기의 Mesh는 충돌만 하고 물리 시뮬레이션은 하지 않는다.
플레이어가 배출기를 바라봤는지는 InteractionVolume으로 검사한다.
InteractionVolume = CreateDefaultSubobject<UBoxComponent>(TEXT("InteractionVolume"));
InteractionVolume->SetupAttachment(Root);
InteractionVolume->SetBoxExtent(FVector(75.f, 75.f, 100.f));
InteractionVolume->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
InteractionVolume->SetCollisionObjectType(ECC_WorldDynamic);
InteractionVolume->SetCollisionResponseToAllChannels(ECR_Ignore);
InteractionVolume->SetCollisionResponseToChannel(
MDCollisionChannels::InteractTrace,
ECR_Block);
InteractionVolume->SetGenerateOverlapEvents(false);
배출기 메시의 충돌을 상호작용 판정에 그대로 사용하지 않은 이유는, 메시의 충돌 모양은 아트 작업에 따라 바뀔 수 있기 때문이다.
메시가 교체되거나 충돌이 단순화되면 플레이어가 분명 배출기를 보고 있는데도 Trace가 빗나갈 수 있다.
그래서 역할을 나눴다.
Mesh: 플레이어와 물체를 막는 실제 충돌InteractionVolume: 상호작용 Trace만 받는 단순한 박스InteractionVolume은 모든 채널을 무시하고, 프로젝트에서 상호작용 용도로 만든 InteractTrace 채널만 막는다.

쓰레기통 생성은 게임 월드를 바꾸는 행동이다.
클라이언트가 자기 화면에서 직접 쓰레기통을 만들면 서버와 다른 플레이어는 그 통을 모를 수 있기 때문에, 배출기는 반드시 서버 상호작용 경로를 사용한다.
bool AMD_TrashDispenser::RequiresServerInteraction_Implementation() const
{
return true;
}
UMD_InteractionComponent는 이 값을 확인한다.
const bool bRequiresServerInteraction =
IMD_Interactable::Execute_RequiresServerInteraction(Target);
if (!bPickupTarget && !bRequiresServerInteraction)
{
IMD_Interactable::Execute_Interact(Target, OwnerPawn);
return;
}
ServerInteract(Target);
true를 반환하는 액터는 클라이언트에서 바로 실행하지 않고 ServerInteract()로 요청을 보낸다.
서버는 클라이언트가 보내준 액터를 그대로 믿지 않는다.
if (!IsValid(RequestedActor)
|| !RequestedActor->Implements<UMD_Interactable>()
|| TraceInteractionTarget() != RequestedActor)
{
return;
}
서버 시점에서도 플레이어가 정말 그 배출기를 바라보고 있는지 다시 Trace 해서 처리해준다.
배출기의 Interact()가 실행돼도 다시 한 번 서버 권한을 확인한다.
void AMD_TrashDispenser::Interact_Implementation(AActor* Interactor)
{
if (!HasAuthority()) return;
// ...
}
상호작용 컴포넌트에서 이미 서버 경로를 사용하지만, 배출기 자신도 방어 코드를 가지고 있다.
나중에 다른 코드가 실수로 클라이언트에서 Interact()를 직접 호출하더라도 쓰레기통은 생성되지 않는다.
필요한 값도 함께 검사한다.
if (!IsValid(SpawnPoint) || !IsValid(SpawnClearance) || !TrashBinClass || !GetWorld())
{
MulticastDispenseResult(EMD_TrashDispenseResult::NotConfigured);
return;
}
위에서 조건으로 명시된 TrashBinClass는 실제로 생성할 쓰레기통 클래스다.
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "MD|Trash Dispenser")
TSubclassOf<AMD_TrashBin> TrashBinClass;
SpawnPoint는 쓰레기통이 나오는 배출구를 표현한다.
SpawnPoint = CreateDefaultSubobject<UArrowComponent>(TEXT("SpawnPoint"));
SpawnPoint->SetupAttachment(Root);
SpawnPoint->SetRelativeLocation(FVector(100.f, 0.f, 2.f));
SpawnPoint->SetArrowColor(FColor::Cyan);
SpawnPoint->ArrowSize = 1.5f;
SpawnPoint->bHiddenInGame = true;
USceneComponent가 아닌 UArrowComponent를 사용한 이유는 위치뿐 아니라 배출 방향도 에디터에서 쉽게 확인할 수 있기 때문이다.

여기서 중요한 것은 SpawnPoint를 단순히 기계 앞에 두는 것이 아니라, 실제 메시의 구멍 중심과 배출 방향에 맞춰야 한다.
또한 SpawnPoint는 쓰레기통 메시의 바닥 위치가 아니라 쓰레기통 액터의 피벗 위치다.
쓰레기통 피벗이 가운데 있다면 구멍 바닥에 SpawnPoint를 놓는 순간 쓰레기통 절반이 바닥 아래로 들어갈 수 있다.
그래서 정면뿐 아니라 측면과 위쪽 뷰에서도 위치를 확인해야 한다.
GetComponentTransform()에는 월드 스케일도 포함되는데, 예를 들어 레벨에서 배출기를 1.5배로 키웠다면 SpawnPoint의 월드 스케일도 1.5가 된다.
그 Transform을 그대로 사용하면 새 쓰레기통까지 1.5배 크기로 생성이 되기 때문에 다음과 같이 스케일링을 해줘서 항상 스케일이 BP_TrashBin값으로만 통일되도록 했다.
이제 배출기를 레벨에서 크게 배치해도 쓰레기통 크기까지 함께 커지지 않는다.

배출구에 기존 쓰레기통이 있는데 새 통을 같은 위치에 만들면 두 물리 물체가 겹친다.
겹쳐서 생성된 물체는 서로를 밀어내려고 하기 때문에 갑자기 튀거나, 빠르게 회전하거나, 기계 내부에 끼어버릴 수 있다.
그래서 생성 전에 SpawnClearance라는 상자로 공간을 검사한다.
SpawnClearance = CreateDefaultSubobject<UBoxComponent>(TEXT("SpawnClearance"));
SpawnClearance->SetupAttachment(SpawnPoint);
SpawnClearance->SetRelativeLocation(FVector(0.f, 0.f, 38.f));
SpawnClearance->SetBoxExtent(FVector(40.f, 36.f, 38.f));
SpawnClearance->SetCollisionEnabled(ECollisionEnabled::NoCollision);
SpawnClearance->SetGenerateOverlapEvents(false);
SpawnClearance 자체는 충돌하거나 오버랩 이벤트를 발생시키지 않는다.
이 컴포넌트는 에디터에서 검사 범위와 방향을 보여주는 기준점으로만 사용한다.

실제 검사는 서버가 상호작용을 처리하는 순간 한 번 수행한다.
bool AMD_TrashDispenser::IsSpawnAreaClear() const
{
if (!GetWorld() || !IsValid(SpawnClearance))
{
return false;
}
FCollisionQueryParams QueryParams(SCENE_QUERY_STAT(TrashDispenserClearance), false, this);
QueryParams.AddIgnoredActor(this);
const FCollisionShape ClearanceShape = FCollisionShape::MakeBox(
SpawnClearance->GetScaledBoxExtent());
return !GetWorld()->OverlapBlockingTestByProfile(
SpawnClearance->GetComponentLocation(),
SpawnClearance->GetComponentQuat(),
TEXT("PhysicsActor"),
ClearanceShape,
QueryParams);
}
OverlapBlockingTestByProfile()은 해당 위치에 가상의 상자를 놓았을 때, PhysicsActor 규칙으로 막히는 물체가 있는지 확인한다.
배출기 자신은 검사 대상에서 제외한다.
QueryParams.AddIgnoredActor(this);
배출기는 원래 출구 검사 상자와 가까이 있기 때문에 자신을 제외하지 않으면 항상 “출구가 막혀 있다”고 판단할 수 있다.

공간 검사를 통과했더라도 실제 생성 시점에 충돌이 발생할 가능성은 남아 있다.
그래서 SpawnActor에도 충돌 처리 규칙을 지정했다.
FActorSpawnParameters SpawnParams;
SpawnParams.Owner = this;
SpawnParams.Instigator = Cast<APawn>(Interactor);
SpawnParams.SpawnCollisionHandlingOverride =
ESpawnActorCollisionHandlingMethod::DontSpawnIfColliding;
DontSpawnIfColliding은 이름 그대로 생성 위치에 충돌이 있으면 액터를 만들지 않는다.
앞서 정의한 공간 검사는 실패 이유를 미리 구분하고, DontSpawnIfColliding은 예상하지 못한 겹침을 마지막에 막아준다.
아직 안내 문구, 성공 메시지, 실패 사운드는 구현하지 않았지만, 나중에 Blueprint 연출을 붙일 수 있도록 서버 판정 결과는 미리 분리했다.
UENUM(BlueprintType)
enum class EMD_TrashDispenseResult : uint8
{
Success,
Blocked,
NotConfigured,
SpawnFailed
};
각 값의 의미는 다음과 같다.
| 결과 | 의미 |
|---|---|
Success | 쓰레기통 생성 성공 |
Blocked | 배출구가 다른 물체로 막힘 |
NotConfigured | 생성 클래스나 컴포넌트 설정 누락 |
SpawnFailed | 공간 검사는 통과했지만 실제 생성 실패 |
결과는 멀티캐스트 함수로 전달한다.
UFUNCTION(NetMulticast, Reliable)
void MulticastDispenseResult(EMD_TrashDispenseResult Result);
그리고 실제 소리나 이펙트는 Blueprint가 구현할 수 있도록 이벤트만 열어두었다.
UFUNCTION(BlueprintImplementableEvent, Category = "MD|Trash Dispenser")
void OnDispenseResult(EMD_TrashDispenseResult Result);
현재는 기능 구현 범위를 쓰레기통 생성과 안전장치까지로 제한했다.
안내 문구와 성공·실패 연출은 UI와 사운드 방향이 정해진 뒤 별도 작업으로 추가할 예정이다.