이전 글: 멀티플레이 상품 상태를 enum으로 관리하기에서는 상품 상태를 enum으로 나누고,
멀티플레이 환경에서 서버 기준으로 상태를 관리한 내용을 정리했다.
이번 글에서는 그 다음 단계로,
상품의 물리 충돌과 로봇손 획득 판정을 분리한 내용을 정리해보려고 한다.
충돌을 분리해야 했던 이유
상품은 진열대 위에 놓여 있을 수도 있고, 카트에 담길 수도 있으며,
충돌로 인해 바닥에 떨어질 수도 있다.
이런 흐름을 생각하면 상품에는 크게 두 종류의 충돌이 필요했다.
1. 물리 충돌
-> 바닥, 진열대, 다른 오브젝트와 부딪히기 위한 충돌
2. 로봇손 획득 판정
-> 로봇손이 상품을 잡을 수 있는지 확인하기 위한 충돌
처음에는 상품의 Static Mesh 하나만으로
물리 충돌과 획득 판정을 모두 처리해도 된다고 생각할 수 있다.
하지만 실제 구현을 생각해보면 두 충돌의 목적이 다르다.
물리 충돌은 상품이 월드에서 자연스럽게 움직이기 위한 것이고,
획득 판정은 로봇손이 상품을 잡을 수 있는지 확인하기 위한 것이다.
이 둘을 하나의 충돌 설정으로 처리하면 상태가 바뀔 때마다 충돌 설정이 복잡해지고,
나중에 로봇손 판정을 따로 조절하기도 어려워진다.
그래서 상품의 충돌을 다음처럼 분리했다.
Mesh
-> 상품의 외형과 물리 충돌 담당
GrabCollision
-> 로봇손 획득 판정 담당
이번 글의 핵심은 이 구조다.
Mesh는 물리 충돌용으로 사용하기
상품 Actor에는 UStaticMeshComponent를 RootComponent로 두었다.
Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("StaticMesh"));
Mesh->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
Mesh->SetCollisionProfileName(TEXT("ProductPhysics"));
Mesh->SetSimulatePhysics(false);
Mesh->SetMobility(EComponentMobility::Movable);
SetRootComponent(Mesh);
이 Mesh는 실제 상품의 모습을 보여주는 컴포넌트이면서,
월드에서 물리 충돌을 담당하는 컴포넌트다.
그래서 Collision Profile은 ProductPhysics로 설정했다.

상품이 진열 중이거나 바닥에 떨어져 있을 때는 물리 시뮬레이션과 충돌이 필요하다.
반대로 로봇손에 잡혔거나, 카트에 적재되었거나,
정산이 끝난 상품은 더 이상 월드에서 물리 충돌 대상으로 남아 있을 필요가 없다.
이런 처리는 이전 글에서 정리한 상품 상태 흐름과 연결된다.
다만 이번 글에서는 상태별 switch 처리 자체보다는
물리 충돌과 획득 판정을 나누었다는 점에 집중하려고 한다.
GrabCollision은 획득 판정용으로 분리하기
상품에는 로봇손 획득 판정을 위한 USphereComponent도 추가했다.
GrabCollision = CreateDefaultSubobject<USphereComponent>(TEXT("GrabCollision"));
GrabCollision->SetupAttachment(Mesh);
GrabCollision->SetSphereRadius(65.f);
GrabCollision->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
GrabCollision->SetCollisionProfileName(TEXT("ProductGrab"));
GrabCollision은 로봇손이 Trace 또는 Sweep을 수행했을 때
상품을 잡을 수 있는지 확인하기 위한 판정 영역이다.

그래서 QueryOnly로 설정했다.
Mesh
-> QueryAndPhysics
-> 물리 충돌 담당
GrabCollision
-> QueryOnly
-> 로봇손 획득 판정 담당
이렇게 분리하면 상품의 물리 충돌과 로봇손 획득 판정을 서로 다른 기준으로 제어할 수 있다.
예를 들어 상품 Mesh의 물리 충돌 범위와 로봇손으로 잡히는 판정 범위가 항상 같을 필요는 없다.
상품 모델의 모양이 복잡하거나 작게 보이더라도 로봇손 판정은 조금 더 넉넉하게 줄 수 있다.
이번 구현에서는 GrabCollision을 Sphere 형태로 두어
로봇손이 상품을 잡는 판정을 별도로 관리했다.
상품 상태와 획득 판정 연결하기
로봇손 획득 판정용 GrabCollision은 상품 상태에 따라 켜고 끄도록 했다.
Display와 Falling 상태의 상품은 로봇손으로 다시 잡을 수 있어야 하므로GrabCollision을 활성화한다.
반대로 Grabbed, Loaded, Paid 상태의 상품은
이미 잡혔거나, 적재되었거나, 정산된 상태이므로 다시 잡히면 안 된다.
Display / Falling
-> GrabCollision 활성화
Grabbed / Loaded / Paid
-> GrabCollision 비활성화
Trace Channel과 Collision Preset을 따로 만든 이유
로봇손 Sweep은 별도의 Trace Channel을 사용하도록 만들었다.

처음에는 로봇손이 상품의 GrabCollision만 감지하면 된다고 생각할 수 있다.
하지만 실제 맵에는 상품만 있는 것이 아니다.
마트 안에는 진열대, 카트, 벽, 바닥 같은 오브젝트도 있고,
나중에는 로봇손이 이런 오브젝트에 막혀야 하는 상황도 생길 수 있다.
예를 들어 상품이 진열대 뒤에 있는데
로봇손이 진열대를 무시하고 상품을 바로 잡아버리면 이상하다.
그래서 로봇손 판정을 단순히 “상품을 찾는 판정”으로만 보지 않고,
“로봇손이 어떤 오브젝트와 반응해야 하는지 관리하는 충돌 규칙”으로 분리하려고 했다.
최종적으로 의도한 로봇손 Trace 구조는 다음과 같다.
로봇손 Trace
-> 상품의 GrabCollision에 닿으면 획득 시도
-> 진열대나 카트에 막히면 획득 실패
-> 관련 없는 오브젝트는 무시
이번에 Trace Channel, Object Channel, Collision Preset을 따로 만든 이유는
나중에 오브젝트가 추가되더라도 로봇손 판정을 일관되게 관리하기 위해서였다.
로봇손의 상품 확인 판정
로봇손은 조준 방향으로 바로 상품을 가져오는 방식이 아니다.
먼저 서버에서 조준 방향으로 Sweep을 수행해 사거리 안에 잡을 수 있는 상품이 있는지 확인한다.
bool UCartGrabComponent::PerformGrabTrace(FVector_NetQuantizeNormal AimDirection, FHitResult& Hit)
{
UWorld* World = GetWorld();
if (!IsValid(World)) return false;
AActor* OwnerActor = GetOwner();
if (!IsValid(OwnerActor)) return false;
FVector Start = OwnerActor->GetActorLocation();
FVector End = Start + AimDirection * GrabRange;
FCollisionQueryParams Params;
Params.AddIgnoredActor(OwnerActor);
bool bHit = World->SweepSingleByChannel(
Hit,
Start,
End,
FQuat::Identity,
ECC_GameTraceChannel2,
FCollisionShape::MakeSphere(GrabRadius),
Params
);
if (!bHit)
{
float Distance = FVector::Distance(Start, End);
float ReachDuration = Distance / FMath::Max(GrabSpeed, 1.f);
Multicast_PlayGrab(Start, End);
SetGrabCooldown(ReachDuration * 2.f);
return false;
}
return true;
}
여기서는 SweepSingleByChannel()을 사용했다.
라인 하나만 쏘는 LineTrace가 아니라 일정 반지름을 가진 Sphere Sweep을 사용한 이유는
로봇손이 어느 정도 두께를 가진 판정으로 상품을 잡는 느낌을 주기 위해서다.
이렇게 하면 마우스 조준이 조금 어긋나더라도 로봇손의 두께 안에 들어온 상품은 잡을 수 있다.
클라이언트는 방향만 보내고 서버가 판정한다
멀티플레이에서는 클라이언트가 직접 상품을 획득 처리하면 안 된다.
여러 플레이어가 같은 상품을 동시에 노릴 수 있고,
클라이언트마다 다른 결과가 나오면 안 되기 때문이다.
그래서 클라이언트는 조준 방향만 서버로 보내고,
실제 상품 판정은 서버에서 수행하도록 했다.
void UCartGrabComponent::RequestGrab()
{
if (CachedAimDirection.IsNearlyZero()) return;
if (GrabSpeed <= 0.f) return;
Server_GrabProduct(CachedAimDirection);
}
서버 RPC에서는 받은 방향을 기준으로 상품 확인, 도달 시간 계산, 연출 요청을 이어서 처리한다.
void UCartGrabComponent::Server_GrabProduct_Implementation(FVector_NetQuantizeNormal AimDirection)
{
UWorld* World = GetWorld();
if (!IsValid(World)) return;
AActor* OwnerActor = GetOwner();
if (!IsValid(OwnerActor) || !OwnerActor->HasAuthority()) return;
if (!bCanGrab) return;
FHitResult Hit;
if (!PerformGrabTrace(AimDirection, Hit)) return;
AProductBase* Product = Cast<AProductBase>(Hit.GetActor());
if (!IsValid(Product)) return;
GrabbedProduct = Product;
FVector StartLocation = OwnerActor->GetActorLocation();
FVector TargetLocation = Product->GetActorLocation();
float Distance = FVector::Distance(StartLocation, TargetLocation);
float ReachDuration = Distance / FMath::Max(GrabSpeed, 1.f);
World->GetTimerManager().SetTimer(
TryGrabTimer,
this,
&ThisClass::TryGrabProduct,
ReachDuration,
false
);
Multicast_PlayGrab(StartLocation, TargetLocation);
SetGrabCooldown(ReachDuration * 2.f);
}
이 구조에서 클라이언트는 “이 방향으로 로봇손을 발사하고 싶다”는 요청만 보낸다.
실제로 그 방향에 상품이 있는지, 그 상품을 잡을 수 있는지는 서버가 판단한다.
클라이언트
-> 조준 방향 계산
-> 서버에 Grab 요청
서버
-> Sweep으로 상품 확인
-> 잡을 수 있는 상품인지 판단
-> 로봇손 연출 요청
-> 이후 상품 상태 변경과 적재 처리 진행
이렇게 하면 조작 입력은 클라이언트에서 처리하면서도,
상품 획득 결과는 서버 기준으로 일관되게 관리할 수 있다.
바로 Grabbed로 바꾸지 않은 이유
로봇손이 상품을 향해 발사되었을 때 서버에서는 먼저 Sweep으로 사거리 안의 상품을 확인한다.
하지만 Sweep에 상품이 맞았다고 해서 그 순간 바로 상품을 Grabbed 상태로 바꾸지는 않았다.
그 이유는 로봇손이 즉시 맞는 레이저가 아니라 실제로 뻗어가서 상품을 잡는 방식이기 때문이다.
만약 멀리 있는 플레이어가 먼저 클릭했고,
가까이 있는 플레이어가 조금 늦게 클릭했다고 가정해보자.
클릭 순간에 바로 Grabbed로 바꾼다면 멀리 있는 플레이어가 먼저 상품을 선점하게 된다.
하지만 화면상으로는 멀리 있는 플레이어의 로봇손이 아직 상품에 도달하지 않았고,
가까운 플레이어의 로봇손이 더 먼저 상품에 닿을 수 있다.
이 경우에는 가까운 플레이어가 상품을 먼저 가져가는 것이 더 자연스럽다.
멀리 있는 플레이어
-> 먼저 클릭
-> 손이 도달하는 데 오래 걸림
가까운 플레이어
-> 나중에 클릭
-> 손이 더 빨리 도달함
-> 먼저 도달했다면 먼저 획득하는 것이 자연스러움
그래서 로봇손이 발사된 순간에는 상품을 바로 확정하지 않고,
상품까지의 거리와 로봇손 속도를 이용해 도달 시간을 계산했다.
float Distance = FVector::Distance(StartLocation, TargetLocation);
float ReachDuration = Distance / FMath::Max(GrabSpeed, 1.f);
그리고 손이 상품에 도달하는 시점에
실제로 상품을 잡을 수 있는지 다시 확인하도록 했다.
World->GetTimerManager().SetTimer(
TryGrabTimer,
this,
&ThisClass::TryGrabProduct,
ReachDuration,
false
);
도달 시간이 되었을 때 상품이 아직 잡을 수 있는 상태라면
그때 Grabbed 상태로 바꾼다.
반대로 그 사이에 다른 플레이어가 먼저 도달해서 상품을 잡았다면TrySetGrabbed()가 실패하고 해당 로봇손은 상품을 가져가지 못한다.
이렇게 하면 상품 획득 판정이 클릭 시점이 아니라
로봇손이 실제로 상품에 도달한 시점을 기준으로 처리된다.
만약 클릭 순간에 바로 획득되는 구조였다면
로봇손보다는 레이저나 즉발 스킬에 가까운 판정이 되었을 것이다.
이번 게임에서는 로봇손이 뻗어가서 상품을 잡는 느낌을 살리고 싶었기 때문에,
도달 시간 이후에 획득을 확정하는 방식으로 구현했다.
실제 상품과 연출용 메시 분리하기
로봇손이 상품에 도달해 Grabbed 상태가 되면 실제 상품 Actor는 월드에서 숨겨진다.
대신 로봇손에 붙어 보이는 상품은 별도의 연출용 메시를 사용했다.
void UCartGrabComponent::ShowVisualProductMesh(AProductBase* Product)
{
EnsureVisualComponents();
if (!IsValid(Product) || !IsValid(VisualProductMesh)) return;
UStaticMesh* ProductMesh = Product->GetProductMesh();
if (!IsValid(ProductMesh)) return;
VisualProductMesh->SetStaticMesh(ProductMesh);
VisualProductMesh->SetVisibility(true);
Product->SetActorHiddenInGame(true);
}
처음에는 실제 상품 Actor를 로봇손에 직접 Attach해서
손에 붙어 이동하는 것처럼 보이게 만들 수도 있다고 생각했다.
하지만 실제로 구현해보니 멀티플레이 환경에서는 이 방식이 생각보다 까다로웠다.
상품은 서버에서 상태가 바뀌고, 위치 복제와 물리 상태 변경도 함께 일어난다.
여기에 Attach까지 섞이면 클라이언트에서 내가 원하는 연출 순서대로 자연스럽게 보장하기 어려웠다.
이전 HardSnap 문제처럼,
서버에서 계산한 결과가 클라이언트에 반영되는 타이밍과
연출에서 기대하는 타이밍이 어긋날 수 있었다.
또 실제 상품 Actor를 손에 직접 붙이는 방식은
물리, 충돌, 위치 복제, Attach 상태를 함께 신경 써야 해서 구조가 복잡해졌다.
그래서 실제 상품 Actor는 서버 기준의 게임 로직에만 사용하고,
로봇손에 붙어 보이는 상품은 별도의 VisualProductMesh로 처리했다.
실제 상품 Actor
-> 서버 기준 상태 관리
-> 물리 / 충돌 / 적재 처리
-> Grabbed 상태가 되면 숨김
VisualProductMesh
-> 로봇손에 붙어 보이는 연출 전용 메시
-> 충돌 없음
-> 실제 상품의 StaticMesh만 복사해서 표시
이렇게 분리하면 실제 상품의 상태는 서버 기준으로 안정적으로 관리하고,
클라이언트에서는 로봇손이 상품을 들고 돌아오는 모습을 별도 메시로 표현할 수 있다.
즉, 실제 상품은 게임 로직을 담당하고, 연출용 메시에는 보여주는 역할만 맡긴 것이다.
로봇손 회수가 끝났을 때 실제 적재 처리하기
로봇손이 상품에 도달해 Grabbed 상태로 바뀐 뒤,
손이 다시 돌아오는 시간이 끝나면 실제 상품을 카트에 적재한다.
void UCartGrabComponent::HandleFinishGrab()
{
bCanGrab = true;
AActor* OwnerActor = GetOwner();
if (!IsValid(OwnerActor) || !OwnerActor->HasAuthority()) return;
AProductBase* Product = GrabbedProduct.Get();
GrabbedProduct.Reset();
if (!IsValid(Product)) return;
UCartLoadComponent* LoadComp = OwnerActor->FindComponentByClass<UCartLoadComponent>();
if (!IsValid(LoadComp))
{
Product->SetProductState(EProductState::Display);
return;
}
if (!LoadComp->TryAddProduct(Product))
{
Product->SetProductState(EProductState::Display);
return;
}
}
여기서도 서버 권한을 확인한 뒤 처리한다.
카트 적재 컴포넌트를 찾지 못했거나 상품 적재에 실패하면 상품 상태를 다시 Display로 되돌린다.
Grab 성공
-> Grabbed 상태
-> 로봇손 회수 완료
-> TryAddProduct 성공
-> Loaded 상태
적재 실패
-> Display 상태로 되돌림
즉, 로봇손이 상품에 닿은 시점과 상품이 카트에 실제로 적재되는 시점을 분리했다.
이렇게 하면 로봇손이 뻗고 돌아오는 연출 흐름과 실제 상품 적재 로직을 맞출 수 있다.
실제 인게임 사진

아쉬운 점과 개선할 점
현재는 로봇손이 상품의 GrabCollision을 감지해서 획득 판정을 하는 구조까지 구현했다.
다만 아직 맵에 배치된 모든 오브젝트의 Collision Preset을 완전히 정리한 것은 아니다.
그래서 진열대나 다른 카트처럼
나중에는 로봇손을 막아야 할 오브젝트를 현재는 통과하는 경우가 있다.
이 부분은 기능 구현 단계에서는 우선순위를 낮게 두고,
상품 획득 판정이 정상적으로 동작하는지를 먼저 확인했다.
이후 폴리싱 과정에서는 진열대, 벽, 카트 같은 오브젝트들이
로봇손 Trace에 어떻게 반응해야 하는지 정리할 예정이다.
예를 들어 진열대나 벽은 로봇손을 막고,
상품의 GrabCollision은 획득 대상으로 반응하며,
그 외 연출과 무관한 오브젝트는 무시하도록 Collision Preset을 다듬을 수 있다.
현재는 완성된 충돌 규칙이라기보다는,
로봇손 획득 판정을 분리해서 관리할 수 있는 기반을 만든 단계라고 볼 수 있다.
마무리
이번에 로봇손의 획득 판정, 연출, 충돌 처리를 구현하면서
서버와 클라이언트의 역할을 어떻게 나눌지가 가장 큰 고민이었다.
특히 실제 상품 Actor를 로봇손에 직접 Attach하는 방식은
이전 HardSnap 문제처럼 동기화가 생각보다 자연스럽지 않았고,
결국 실제 상품과 연출용 메시를 분리하는 방식으로 정리했다.
아직 진열대나 다른 카트처럼 로봇손을 막아야 하는 오브젝트들의 충돌 설정은 완전히 다듬지 못했지만,
Trace Channel과 Collision Preset을 분리해둔 덕분에
이후 폴리싱 과정에서 더 자연스럽게 개선할 수 있을 것 같다.
이번 구현을 통해 멀티플레이에서는
실제 게임 로직과 클라이언트 연출을 분리해서 생각하는 것이 중요하다는 것을 다시 느꼈다.
'언리얼 엔진 > 프로젝트' 카테고리의 다른 글
| [언리얼 엔진] 멀티플레이 상품 상태를 enum으로 관리하기 (0) | 2026.06.24 |
|---|---|
| [언리얼 엔진] 멀티플레이 상품 Drop 중 HardSnap 문제 해결 (0) | 2026.06.22 |
| [언리얼 엔진] 팀 프로젝트 기록 11 - 레벨 스트리밍 동기화와 프로젝트 마무리 (0) | 2026.05.29 |
| [언리얼 엔진] 팀 프로젝트 기록 10 - AnimNotifyState로 전투 모션 타이밍 제어하기 (0) | 2026.05.27 |
| [언리얼 엔진] 팀 프로젝트 기록 09 - 전투 UI와 데미지 팝업 구현하기 (0) | 2026.05.22 |