지난 편에서 수집품을 납품처에 제출하는 것까지 만들었다. 이제 두 납품소가 목표치를 다 채우면 열리는 탈출구(Escape Gate)를 구현할 차례이다.
탈출구는 먼저 다음과 같이 동작한다.
기존 상호작용 인터페이스(ISPInteractable)에는 "이걸 지금 상호작용할 수 있냐"를 묻는 함수가 없어서 개별 처리를 해줘야 했기 때문에 복잡한 부분이 있었다. 그래서 이미 다 채운 납품소나, 아직 열 수도 없는 탈출구에까지 조준 하이라이트가 뜨던 문제가 발생했다.
인터페이스에 기본값이 true인 함수를 하나 추가했다.
UFUNCTION(BlueprintNativeEvent, Category = "Interaction")
bool IsInteractable() const;
virtual bool IsInteractable_Implementation() const { return true; }
그리고 조준·상호작용 트레이스에서 이 조건을 함께 확인한 뒤, 통과한 대상만 상호작용을 실행한다.
if (TraceInteractable(Hit) && Hit.GetActor()->Implements<USPInteractable>()
&& ISPInteractable::Execute_IsInteractable(Hit.GetActor()))
{
ISPInteractable::Execute_Interact(Hit.GetActor(), this);
}
각 대상은 자기 상황에 맞게 이 함수를 구현한다.
// 납품소: 목표치를 다 채우면 더 이상 대상이 아니다
bool ASPDeliveryStation::IsInteractable_Implementation() const
{
return !IsComplete();
}
// 탈출구: 개방 가능하고, 아직 열리지 않았을 때만 대상이 된다
bool ASPEscapeGate::IsInteractable_Implementation() const
{
if (bIsActivated) return false;
const AMatchGameState* GS = GetWorld() ? GetWorld()->GetGameState<AMatchGameState>() : nullptr;
return GS && GS->CanActivateEscapeGates();
}
기본값을 true로 둔 덕분에, 수집품처럼 "항상 상호작용 가능한" 대상들은 아무것도 고칠 필요가 없었다.
조준 트레이스는 Interactable 콜리전 프로파일을 대상으로 쏜다. 그래서 게이트가 처음부터 그 프로파일이면, 아직 열 수도 없는데 조준부터 되는 문제가 있다.
그래서 게이트는 평소엔 트레이스에서 아예 빠지도록 NoCollision으로 두고, 납품이 완료돼 "개방 가능" 알림이 오면 그때 Interactable로 바꾼다.
void ASPEscapeGate::OnEscapeAvailabilityChanged(bool bCanActivate)
{
if (bIsActivated) return;
Mesh->SetCollisionProfileName(
bCanActivate ? TEXT("Interactable") : TEXT("NoCollision"));
}
한 가지 함정이 있었다. BeginPlay 시점엔 GameState가 아직 없을 수 있다.
GameState는 매치가 시작될 때 GameMode가 생성하는데, 특히 클라이언트에서는 서버로부터 복제되어 뒤늦게 도착한다. 게다가 레벨에 미리 배치된 탈출구의 BeginPlay가 도는 시점과 GameState가 준비되는 시점은 순서가 보장되지 않는다. 그래서 그 순간 GetGameState()가 아직 nullptr이라 델리게이트 구독이 조용히 실패할 수 있다.
그래서 GameState가 준비될 때까지 다음 틱에 재시도하고, 구독하자마자 현재 상태를 한 번 반영하도록 했다.
void ASPEscapeGate::BindAvailabilityDelegate()
{
AMatchGameState* GS = GetWorld() ? GetWorld()->GetGameState<AMatchGameState>() : nullptr;
if (!GS)
{
// 아직 준비 안 됐으면 다음 틱에 다시 시도
GetWorldTimerManager().SetTimerForNextTick(this, &ASPEscapeGate::BindAvailabilityDelegate);
return;
}
GS->OnEscapeGateAvailabilityChanged.AddDynamic(this, &ASPEscapeGate::OnEscapeAvailabilityChanged);
OnEscapeAvailabilityChanged(GS->CanActivateEscapeGates());
}
원래는 "모든 게이트가 다 열려야" 탈출로 쳤는데, 팀에서 사양을 게이트마다 독립으로 정리했다. 각 게이트가 자기 볼륨을 갖고, 열린 게이트를 통과한 사람이 탈출한다.
통과 판정을 자기 자신의 상태로 단순화했다.
void ASPEscapeGate::OnExitTriggerBeginOverlap(...)
{
if (!HasAuthority()) return; // 탈출 처리는 서버에서만
if (ASurvivorCharacter* Survivor = Cast<ASurvivorCharacter>(OtherActor))
{
if (bIsActivated) // 이 게이트가 열려 있으면
{
Survivor->SetSurvivorState(ESurvivorState::Escaped);
}
}
}