이전 글에서는 플레이어가 빈 쓰레기통을 꺼낼 수 있도록 AMD_TrashDispenser를 구현했다.
쓰레기통 생성은 게임 월드를 변경하기 때문에 클라이언트에서 직접 실행하지 않고, 다음 함수를 통해 서버 상호작용 경로를 사용했다.
bool AMD_TrashDispenser::RequiresServerInteraction_Implementation() const
{
return true;
}
당시에는 배출기처럼 서버 실행이 필요한 액터만 true를 반환하도록 했다.
하지만 상호작용 가능한 액터가 늘어나면서 문제가 보이기 시작했다.
그래서 이번에는 상호작용 시스템의 기본 규칙을 다시 정해보려고 한다.
기존에는 IMD_Interactable의 서버 실행 기본값이 false였다.
virtual bool RequiresServerInteraction_Implementation() const
{
return false;
}
각 액터가 필요할 때만 true를 반환하는 구조였다.
이 구조는 UI 상호작용에는 편하지만, 새로운 상태 변경형 액터가 설정을 빠뜨렸을 때 조용히 문제가 발생한다.
예를 들어 다음과 같은 액터가 새로 추가됐다고 가정할 수 있다.
개발자가 RequiresServerInteraction() 재정의를 빠뜨리면 로컬 플레이에서는 정상처럼 보인다.
하지만 멀티플레이에서는 해당 클라이언트의 화면에서만 문이 열리거나, 클라이언트에만 아이템이 생기는 상황이 발생할 수 있다.
즉, 실수했을 때 안전하지 않은 방향으로 동작하는 구조였다.
물리 운반에도 문제가 있었다.
기존에는 서버가 물체를 잡으면 HeldActor를 복제하고, 각 클라이언트가 자신의 HoldPoint 위치로 물체를 직접 따라오게 했다.
Target->SetReplicateMovement(false);
클라이언트에서는 복제된 HeldActor의 물리를 끄고 직접 위치를 변경했다.
이 방식은 화면 반응은 빠르지만, 서버와 클라이언트가 서로 다른 물리 결과를 만들 수 있다.
예를 들어 서버에서는 물체가 벽에 걸려 있지만 클라이언트는 물체를 카메라 앞으로 이동시킬 수 있다.
그 결과로, 다음과 같이 클라이언트마다 물체의 최종 위치가 다르게 보일 수 있다.

가장 먼저 상호작용 인터페이스의 기본 정책을 변경했다.
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "MD|Interaction")
bool RequiresServerInteraction() const;
virtual bool RequiresServerInteraction_Implementation() const
{
return true;
}
이제 새로운 Interactable은 아무 설정을 하지 않아도 서버 경로를 사용한다.
실수했을 때 클라이언트에서만 실행되는 대신, 서버에서 실행되는 쪽으로 동작한다.
이를 안전한 기본값으로 정했다.
UMD_InteractionComponent::Interact()는 대상의 정책을 확인한다.
void UMD_InteractionComponent::Interact()
{
APawn* OwnerPawn = GetOwnerPawn();
if (!IsValid(OwnerPawn) || !CanOwnerInteract())
{
SetCurrentTarget(nullptr);
return;
}
AActor* Target = CurrentTarget.Get();
if (IsValid(Target) && Target->Implements<UMD_Interactable>())
{
const bool bRequiresServerInteraction =
IMD_Interactable::Execute_RequiresServerInteraction(Target);
if (!bRequiresServerInteraction)
{
IMD_Interactable::Execute_Interact(Target, OwnerPawn);
return;
}
}
ServerInteract(Target);
}
전체 흐름은 다음과 같다.
Interact()를 호출한다ServerInteract() RPC로 요청한다모든 상호작용이 서버에서 실행돼야 하는 것은 아니다.
다음처럼 플레이어 개인의 UI만 여는 대상은 로컬 실행이 더 적합하다.
이 액터들은 false를 명시적으로 반환하도록 했다.
virtual bool RequiresServerInteraction_Implementation() const override
{
return false;
}
클라이언트가 상호작용 버튼을 눌렀다는 사실만으로 요청을 허용하면 안 된다.
서버에서는 플레이어가 정말 상호작용할 수 있는 상태인지 다시 확인해야 한다.
이 검사를 CanOwnerInteract()로 모았다.
bool UMD_InteractionComponent::CanOwnerInteract() const
{
APawn* OwnerPawn = GetOwnerPawn();
if (!IsValid(OwnerPawn) || bInteractionBlocked)
{
return false;
}
const AController* Controller = OwnerPawn->GetController();
if (!IsValid(Controller) || Controller->GetPawn() != OwnerPawn)
{
return false;
}
const IAbilitySystemInterface* AbilitySystemOwner =
Cast<IAbilitySystemInterface>(OwnerPawn);
const UAbilitySystemComponent* AbilitySystem =
AbilitySystemOwner
? AbilitySystemOwner->GetAbilitySystemComponent()
: nullptr;
return !IsValid(AbilitySystem)
|| !AbilitySystem->HasMatchingGameplayTag(
MDGameplayTags::State_Dead);
}
확인하는 조건은 다음과 같다.
| 검사 | 이유 |
|---|---|
| Pawn이 유효한가 | 이미 제거된 캐릭터의 요청 차단 |
| Interaction이 차단됐는가 | 사망 등 외부 상태 반영 |
| Controller가 Pawn을 소유하는가 | 빙의가 끝난 Pawn의 요청 차단 |
| Controller의 현재 Pawn이 맞는가 | Controller 변경 과정의 오래된 요청 차단 |
State.Dead 태그가 없는가 | 죽은 플레이어의 상호작용 차단 |
이 검사는 로컬 입력뿐 아니라 서버 RPC에서도 다시 실행한다.
void UMD_InteractionComponent::ServerInteract_Implementation(
AActor* RequestedActor)
{
APawn* OwnerPawn = GetOwnerPawn();
if (!IsValid(OwnerPawn) || !OwnerPawn->HasAuthority())
{
return;
}
if (!CanOwnerInteract())
{
ReleaseHeldActor();
return;
}
// ...
}
클라이언트가 변조된 요청을 보내더라도 서버 조건을 통과하지 못하면 실행되지 않는다.
기존에는 플레이어가 물체를 든 상태에서 사망해도 이동만 비활성화됐다.
물리 핸들은 물체를 계속 잡고 있을 수 있었다.
부활하면서 캐릭터만 모선으로 이동하면 물체가 따라오거나, 이전 위치와 모선 사이에서 이상하게 움직일 수 있다.
이를 해결하기 위해 상호작용 컴포넌트에 정리 함수를 추가했다.
UFUNCTION(BlueprintCallable, Category = "MD|Interaction")
void CancelInteraction();
UFUNCTION(BlueprintCallable, Category = "MD|Interaction")
void ReleaseHeldActor();
UFUNCTION(BlueprintCallable, Category = "MD|Interaction")
void SetInteractionBlocked(bool bBlocked);
CancelInteraction()은 현재 조준 대상을 지우고, 서버라면 들고 있는 물체를 놓는다.
void UMD_InteractionComponent::CancelInteraction()
{
SetCurrentTarget(nullptr);
const APawn* OwnerPawn = GetOwnerPawn();
if (IsValid(OwnerPawn) && OwnerPawn->HasAuthority())
{
ReleaseHeldActor();
}
}
캐릭터는 다음 생명주기에서 이 함수를 호출한다.
void AMD_Character::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
if (IsValid(InteractionComponent))
{
InteractionComponent->CancelInteraction();
}
Super::EndPlay(EndPlayReason);
}
void AMD_Character::UnPossessed()
{
if (IsValid(InteractionComponent))
{
InteractionComponent->CancelInteraction();
}
Super::UnPossessed();
}
텔레포트 직전에도 운반 상태를 정리한다.
if (IsValid(InteractionComponent))
{
InteractionComponent->CancelInteraction();
}
SetActorLocationAndRotation(
RespawnPoint->GetActorLocation(),
RespawnPoint->GetActorRotation(),
false,
nullptr,
ETeleportType::TeleportPhysics);
사망 상태가 변경되면 상호작용 자체를 차단한다.
void AMD_Character::HandleHealthStateChanged(EMD_HealthState NewState)
{
const bool bIsDead =
NewState == EMD_HealthState::Dead;
if (IsValid(InteractionComponent))
{
InteractionComponent->SetInteractionBlocked(bIsDead);
}
ApplyDeathMovementLock(bIsDead);
}
이제 다음 상황에서 물체가 자동으로 해제된다.
UnPossessed()에서도 해제하는 이유는 캐릭터가 파괴되지 않고 Controller만 바뀌는 경우가 있기 때문이다.
이 처리가 없으면 이전 플레이어가 들던 물체가 새로운 Controller에게 그대로 넘어갈 수 있다.