Ch4 팀 프로젝트(2) - Survivor State & Interact 설계

yys·2026년 6월 29일

TIL

목록 보기
62/86

생존자 상태(State) 설계


생존자는 게임 내내 여러 상태를 오간다. 이 상태들을 열거형으로 먼저 정의했다.

UENUM(BlueprintType)
enum class ESurvivorState : uint8
{
	Healthy,   // 건강
	Injured,   // 부상
	Downed,    // 다운(기어다님)
	Carried,   // 살인마에게 들쳐업힘
	Caged,     // 케이지에 갇힘
	Dead,      // 사망
	Escaped    // 탈출 성공
};

멀티플레이에서 생존자의 상태는 나만 아는 정보가 아니기 때문에 살인마도 "저 생존자가 다운됐는지" 봐야 하고, 다른 생존자도 "구출하러 가야 하는지" 알아야 한다. 그래서 상태는 모든 클라이언트에 동기화되어야 한다.

UPROPERTY(ReplicatedUsing="OnRep_SurvivorState")
ESurvivorState SurvivorState = ESurvivorState::Healthy;

단순 Replicated가 아니라 ReplicatedUsing 을 쓴 이유가 있다. 값만 동기화하는 데서 그치지 않고, 상태가 바뀌는 순간 클라이언트에서도 시각·연출 효과(머티리얼 변화, 애니메이션 등)를 갱신해야 하기 때문이다. 그 후처리를 OnRep_SurvivorState() 콜백에 맡긴다.

리플리케이션 대상은 GetLifetimeReplicatedProps에 등록해야 실제로 동기화된다.

void ASurvivorCharacter::GetLifetimeReplicatedProps(TArray<class FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);

	DOREPLIFETIME(ASurvivorCharacter, SurvivorState);
}

상태를 바꾸는 함수는 서버 권위를 그대로 코드로 옮긴 형태다.

void ASurvivorCharacter::SetSurvivorState(ESurvivorState NewState)
{
	if (!HasAuthority() || SurvivorState == NewState) return;

	SurvivorState = NewState;
	ApplyStateEffects();
}
  • HasAuthority()서버에서만 실제 값을 바꾼다. 클라이언트가 호출해도 여기서 막힌다.
  • SurvivorState == NewState — 같은 상태로의 변경은 무시한다. 불필요한 리플리케이션과 효과 재적용을 막기 위해서다.

SurvivorState는 서버에서 복제되어 적용되기 때문에 OnRep_Notify를 통해서 양쪽에서 모두 한 함수(ApplyStateEffects)로 모아준다.

void ASurvivorCharacter::OnRep_SurvivorState()
{
	ApplyStateEffects();   // 클라이언트: 값이 복제되어 도착했을 때
}
  • 서버SetSurvivorState 안에서 직접 ApplyStateEffects() 호출
  • 클라이언트OnRep_SurvivorState에서 ApplyStateEffects() 호출

상태 기반 행동 게이트

상태에 따라 무엇을 할 수 있는지는 작은 판정 함수로 분리했다.

bool ASurvivorCharacter::CanMove() const
{
	return SurvivorState == ESurvivorState::Healthy
		|| SurvivorState == ESurvivorState::Injured
		|| SurvivorState == ESurvivorState::Downed;
}

bool ASurvivorCharacter::CanInteract() const
{
	return SurvivorState == ESurvivorState::Healthy
		|| SurvivorState == ESurvivorState::Injured;
}
  • 다운(Downed) 상태는 기어서 이동은 가능하지만 상호작용은 불가능하다. 그래서 CanMove에는 포함되지만 CanInteract에는 빠진다.
  • 케이지 · 운반 · 사망 · 탈출 상태는 이동도 상호작용도 모두 불가능하다.

이렇게 행동을 함수 하나로 게이팅해 두면, 나중에 이동·상호작용·스킬 등 어디서든 if (CanInteract()) 한 줄로 일관되게 막을 수 있다.

상호작용(Interact)


생존자가 상호작용하는 대상은 다양하다. 수집품, 납품소, 케이지, 탈출구… 대상마다 동작은 전부 다르다. 하지만 생존자 입장에서는 모두 "바라보고 E를 누른다"로 똑같다.

만약 대상 종류마다 if (수집품) ... else if (납품소) ... 식으로 분기하면, 대상이 늘어날수록 생존자 코드가 복잡해지기 때문에 인터페이스로 풀었다.

// Interactable.h
UINTERFACE()
class UInteractable : public UInterface
{
	GENERATED_BODY()
};

class SPACH4_API IInteractable
{
	GENERATED_BODY()

public:
	UFUNCTION(BlueprintNativeEvent, Category = "Interaction")
	void Interact(AActor* Instigator);
};

Interact를 통해서 이를 상속한 액터가 어떻게 상호작용 되는지를 정의할 수 있고 C++, BP에서 둘 다 정의할 수 있도록 BlueprintNativeEvent를 넣어주었다.

입력 -> 서버 요청

입력 바인딩은 생존자·살인마가 공유하는 공통 베이스(ACharacterBase)에 두었다. Enhanced Input으로 묶는다.

// CharacterBase.cpp — SetupPlayerInputComponent
EnhancedInput->BindAction(
	InputConfig->InteractAction,
	ETriggerEvent::Started,          
	this, &ThisClass::Interact);

참고로 콜백 함수를 부모 클래스로 선언을 해도 호출한 쪽의 액터를 기반으로 오버라이드하기 때문에 정적으로 처리되지 않는다.

void ASurvivorCharacter::Interact()
{
	Super::Interact();

	Server_Interact();   // 클라이언트는 직접 처리하지 않고 "서버에 요청"
}

여기가 멀티플레이의 핵심이다. 클라이언트는 상호작용을 직접 처리하지 않는다. 클라이언트가 스스로 판정하면 조작이 가능하기 때문에, 대신 서버에 "상호작용"을 요청한다.

UFUNCTION(Server, Reliable, WithValidation)
void Server_Interact();
  • Server — 클라이언트가 호출하면 서버에서 실행된다.
  • Reliable — 반드시 도착을 보장한다. 상호작용은 누락되면 안 되는 중요한 행동이라 신뢰성 채널을 쓴다.
  • WithValidation — 서버에서 요청의 유효성을 검사한다. (지금은 모든 요청을 신뢰하도록 비워뒀다.)
bool ASurvivorCharacter::Server_Interact_Validate()
{
	// 일단 모든 요청이 신뢰가 있다고 가정
	return true;
}

서버에서의 상호작용 판정

실제 판정은 서버에서 일어난다. 카메라가 바라보는 방향으로 트레이스를 쏴서, 그 끝에 상호작용 대상이 있는지 확인하는 흐름이다.

void ASurvivorCharacter::Server_Interact_Implementation()
{
	if (!CanInteract()) return;                       

	FVector CameraLocation;
	FRotator CameraRotation;
	GetController()->GetPlayerViewPoint(CameraLocation, CameraRotation);  // 카메라의 시점 구하기

	const FVector Start = CameraLocation;
	const FVector End   = Start + CameraRotation.Vector() * InteractReach;

	FCollisionQueryParams Params;
	Params.AddIgnoredActor(this);

	FHitResult Hit;
	const bool bHit = GetWorld()->SweepSingleByChannel(  // 구체 스윕
		Hit, Start, End, FQuat::Identity,
		ECC_GameTraceChannel1,                           // 커스텀 채널(InteractTrace)
		FCollisionShape::MakeSphere(InteractRadius), Params);

	if (bDrawDebug)
	{
		DrawDebugSphere(GetWorld(), End, InteractRadius, 12, FColor::Green, false, 5.f);
	}

	if (bHit && Hit.GetActor() && Hit.GetActor()->Implements<UInteractable>())  // 판정
	{
		IInteractable::Execute_Interact(Hit.GetActor(), this);
	}
}

카메라의 시점 구하기 — GetPlayerViewPoint로 카메라의 위치와 방향을 얻는다. 화면 정중앙에서 바라보는 방향으로 쏘기 위함이다.

구체 스윕 — 단순한 라인 트레이스가 아니라 구체(Sphere) 를 날린다(SweepSingleByChannel). 라인은 정확히 조준해야 맞지만, 구체는 약간의 오차가 있어도 작은 물체를 잘 잡아낸다. InteractReach만큼의 거리에 InteractRadius 굵기로 던진다.

인터페이스 판정 — 맞은 액터가 IInteractable을 구현했다면 Execute_Interact로 호출한다.

예시 글에서 이동 벡터를 기즈모로 그려 확인했듯, 이번에도 트레이스 끝 지점을 디버그 구체로 그려 실제로 어디를 향하는지 눈으로 확인하며 작업했다.

커스텀 트레이스

기본 채널을 쓰면 벽, 바닥, 단순 소품까지 전부 트레이스에 걸린다. 상호작용 가능한 대상만 정확히 골라내려면 그 대상만 반응하는 전용 채널이 훨씬 낫기 때문에 혼란을 줄이기 위해 다음과 같이 Trace 채널과 Collision 채널을 추가했다.

Interact 테스트


이제 트레이스에 잡혀서 Interact를 호출받는 대상을 테스트해봐야 하므로 디버깅 용도로 IInteractable을 상속하는 액터를 만들어주었다.

ASPInteractableActor::ASPInteractableActor()
{
	PrimaryActorTick.bCanEverTick = false;
	bReplicates = true;                    

	Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
	SetRootComponent(Mesh);

	Mesh->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
	Mesh->SetCollisionObjectType(ECC_WorldDynamic);
	Mesh->SetCollisionResponseToAllChannels(ECR_Ignore);          
	Mesh->SetCollisionResponseToChannel(ECC_GameTraceChannel1, ECR_Block);  
}

콜리전 설정이 앞서 만든 채널과 정확히 짝을 이룬다. 모든 채널을 무시하되, InteractTrace 채널만 Block 한다.

bReplicates = true로 멀티 동기화 대상임을 명시하고, 동기화할 변수는 리플리케이션에 등록한다.

void ASPInteractableActor::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);

	DOREPLIFETIME(ASPInteractableActor, InteractCount);
}

실제 상호작용 처리는 다음과 같다.

void ASPInteractableActor::Interact_Implementation(AActor* Interactor)
{
	if (HasAuthority())
	{
		++InteractCount;        // 카운트는 서버에서만 증가
	}

	OnInteracted(Interactor);   // BP에서 연출(이펙트/사운드) 처리

	if (bDebugLog && GEngine)
	{
		FString Msg = FString::Printf(TEXT("Try Interact: %d"), InteractCount);
		GEngine->AddOnScreenDebugMessage(-1, 3.f, FColor::Cyan, Msg);
	}
}
  • InteractCount는 디버그용 변수로 서버에서만 증가시키고 Replicated로 모든 클라이언트에 전파된다. 카운트라는 "사실(상태)"은 서버가 단일하게 관리한다.
  • OnInteractedBlueprintImplementableEvent다. 이펙트·사운드 같은 표현은 블루프린트에 맡긴다.

다음과 같이 Interact가 정상적으로 이루어짐을 확인할 수 있고 카운트 변수 또한 복제가 잘 이루어짐을 확인할 수 있었다.

profile
게임 개발 지망생

0개의 댓글