Ch4 팀 프로젝트(5) - 탈출구 구현

yys·2026년 7월 2일

TIL

목록 보기
65/86

탈출구 구상

지난 편에서 수집품을 납품처에 제출하는 것까지 만들었다. 이제 두 납품소가 목표치를 다 채우면 열리는 탈출구(Escape Gate)를 구현할 차례이다.

탈출구는 먼저 다음과 같이 동작한다.

  1. 납품소 A·B가 둘 다 목표치를 채운다
  2. 그 순간 모든 탈출구가 "개방 가능" 상태가 된다 (Collision 활성화)
  3. 생존자가 상호작용하면 일정 시간 동안 게이트가 열린다
  4. 열린 게이트를 통과하면 탈출 완료

상호작용 처리

기존 상호작용 인터페이스(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 지연 바인딩 처리

한 가지 함정이 있었다. 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);
        }
    }
}
profile
게임 개발 지망생

0개의 댓글