Ch6 팀 프로젝트(5) - Interact 보완

yys·2026년 8월 11일

TIL

목록 보기
82/86

이번 목표


이전 글에서는 플레이어가 빈 쓰레기통을 꺼낼 수 있도록 AMD_TrashDispenser를 구현했다.

쓰레기통 생성은 게임 월드를 변경하기 때문에 클라이언트에서 직접 실행하지 않고, 다음 함수를 통해 서버 상호작용 경로를 사용했다.

bool AMD_TrashDispenser::RequiresServerInteraction_Implementation() const
{
	return true;
}

당시에는 배출기처럼 서버 실행이 필요한 액터만 true를 반환하도록 했다.

하지만 상호작용 가능한 액터가 늘어나면서 문제가 보이기 시작했다.

  • 새로운 액터에서 서버 실행 설정을 빠뜨리면 클라이언트에서만 상태가 변경될 수 있다
  • 죽은 플레이어가 상호작용 RPC를 보내거나 물체를 계속 들고 있을 수 있다
  • 클라이언트마다 운반 중인 물체의 위치를 직접 움직여 서버와 다른 위치가 보일 수 있다
  • 모든 캐릭터의 상호작용 컴포넌트가 매 프레임 Tick을 사용한다
  • 서버의 보조 판정이 거리와 각도만 확인해 벽 뒤의 액터를 허용할 수 있다
  • 캐릭터가 사망하거나 Controller가 바뀌는 순간 운반 상태가 정리되지 않는다

그래서 이번에는 상호작용 시스템의 기본 규칙을 다시 정해보려고 한다.

기존 구조의 문제


기존에는 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);
}

전체 흐름은 다음과 같다.

  1. 현재 캐릭터가 상호작용 가능한 상태인지 확인한다
  2. 대상이 로컬 실행을 명시했는지 확인한다
  3. 로컬 전용이면 즉시 Interact()를 호출한다
  4. 나머지는 ServerInteract() RPC로 요청한다
  5. 서버가 대상을 다시 검증한 후 실행한다

UI 상호작용


모든 상호작용이 서버에서 실행돼야 하는 것은 아니다.

다음처럼 플레이어 개인의 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);
}

이제 다음 상황에서 물체가 자동으로 해제된다.

  • 플레이어 사망
  • 모선으로 부활 텔레포트
  • Controller의 빙의 해제
  • 캐릭터 제거
  • 상호작용 컴포넌트 종료

UnPossessed()에서도 해제하는 이유는 캐릭터가 파괴되지 않고 Controller만 바뀌는 경우가 있기 때문이다.

이 처리가 없으면 이전 플레이어가 들던 물체가 새로운 Controller에게 그대로 넘어갈 수 있다.

profile
게임 개발 지망생

0개의 댓글