세이브 포인트

  • 캠프라는 이름의 체크 포인트를 정의했다
  • 던전 안에는 여러 캠프가 존재하며, 던전 진입 시 마지막으로 활성화 및 상호작용한 체크포인트의 위치로 스폰되어야 했다
    • 멀티 상황에서 최대 4개의 스폰 포인트가 있고, 스폰 시 특정 애니메이션을 출력해야 했다

진입 시 Spawn Point

AActor* ACustomGameMode::FindPlayerStart_Implementation(AController* Player, const FString& IncomingName)
{
  // ...
  APlayerController* PlayerController = Cast<APlayerController>(Player);
  const int32 PlayerID = FL::GetPlayerID(PlayerController);

  // 레벨 내에 Camp를 먼저 찾는다
  if(AActor* StartSpot = GetCampStartByIndex(PlayerID))
  {
    return StartSpot;
  }
  // ...
}

APlayerStart* ACustomGameMode::GetCampStartByIndex(int32 Index)
{
  ACampActor* DefaultCamp = nullptr;
  
  // 시작 캠프가 없을 때 StartingCamp 탐색 
  if (IsValid(StartingCamp) == false)
  {
    // ActorManagerSubSystem 사용해 GetAllCampActors 호출하려 했으나 타이밍상 아직 액터를 컨테이너에 수집하기 전이라 정상적으로 작동하지 않음.
    // 그래서 UGameplayStatics::GetAllActorsOfClass 함수를 사용 
    TArray<AActor*> AllCampActors;
    UGameplayStatics::GetAllActorsOfClass(this, ACampActor::StaticClass(), AllCampActors);

    for (AActor* Actor : AllCampActors)
    {
      if (ACampActor* CampActor = Cast<ACampActor>(Actor))
      {
        // CampActor->InitializeCampData();

        // 레벨에서 정의된 디폴트 캠프 저장
        if (CampActor->IsActivatedByDefault())
        {
          DefaultCamp = CampActor;
        }

        // 저장 데이터에서 마지막으로 활성화된 캠프인지 확인
        if (CampActor->IsActivatedFromSaveData())
        {
          StartingCamp = CampActor;
          break;
        }
      }
    }
  }
  
  // 탐색 후에도 여전히 래벨에 (활성화된) 캠프가 없는 경우
  if (IsValid(StartingCamp) == false)
  {
    // DefaultCamp에서 시작
    if (IsValid(DefaultCamp))
    {
      StartingCamp = DefaultCamp;
    }

    // DefaultCamp도 없으면 그냥 nullptr 반환
    if (IsValid(StartingCamp) == false)
    {
      return nullptr;	
    }
  }

  APlayerStart* PlayerStartInCamp = StartingCamp->GetPlayerStart(Index);

  // 캠프의 PlayerStart가 Valid하지 않은 경우
  if (IsValid(PlayerStartInCamp) == false)
  {
    return nullptr;
  }

  return PlayerStartInCamp;
}
  • GameMode의 FindPlayerStart_Implementation 함수를 오버라이드 하고, 그 안에서 자체 로직을 구현한다
  • Player는 접속한 순서대로 고유 아이디를 1 ~ N까지 할당 받으며, 이 Index를 기준으로 Camp Actor 내에 배치된 PlayerStart를 전달받아 이 위치로 스폰한다

Note

post_thumbnail
Is Spatially Loaded 토글은 해제해야 한다
디폴트로 true로 세팅되어 있는데, 이렇게 되면 스트리밍 소스의 범위 내에 있을 때만 로드하기 때문에 월드파티션이 적용된 레벨에서 체크포인트의 역할을 제대로 할 수 없다 너무 멀리 떨어져 있는 구역은 스트리밍이 되어있지 않을 것이고, 그럼 해당 체크포인트 액터도 로드되어 있지 않았을 테니까 말이다.

체크포인트 상태 처리

  • 캠프는 마지막으로 활성화된 상태, 활성화된 상태, 비활성화된 상태 등으로 구분되어야 했다
  • 마지막으로 활성화한 캠프는 레벨 진입 시 캐릭터가 최초 스폰되는 지역이다
  • 활성화된 캠프로만 텔레포트할 수 있다
UCLASS()
class ACustomPlayerStateBase : public APlayerState
{
  GENERATED_BODY()

public:

  // ..

private:
  
  // 현재 플레이어의 ID
  UPROPERTY(Replicated)
  int32 RepPlayerID = -1;
  
  // 마지막으로 활성화된 캠프 ID
  UPROPERTY()
  int32 ActivatedCampID = -1;

  // 발견한 캠프 ID 목록
  UPROPERTY()
  TArray<int32> DiscoveredCampIDs;

  • 이들의 관리는 PlayerState에 버퍼를 두고 관리했다
  • 데이터 업데이트 필요 시 로드하거나, 세이브 한다
  • 활성화 시 연출은 동기화되어야 하며, 한 번 활성화한 캠프는 다른 플레이어에 의해 다시 활성화를 거치면 안되니, 캠프의 활성화 상태는 Replicated 됐어야 한다
    • 비활성화, 활성화 이외에도 상태가 다양했다면 GameplayTag를 사용해도 좋았을 거 같다

멀티 코옵 시 4개의 플레이어 Spawn Point

post_thumbnail

  • 최대 4인의 스폰 포인트가 필요했다
UCLASS()
class UCustomPlayerStartComponent : public UChildActorComponent
{
  GENERATED_BODY()

public:
  UCustomPlayerStartComponent(const FObjectInitializer& ObjectInitializer);

  class APlayerStart* GetPlayerStart() const;
  void PlayAbl(AActor* InPlayerCharacter, TSubclassOf<class UAblAbility> InAblToPlay);
    
  FORCEINLINE TSubclassOf<class UAblAbility> GetSpawnAbl() const { return SpawnAbl; }
  FORCEINLINE TSubclassOf<class UAblAbility> GetTeleportAbl() const { return TeleportAbl; }

private:
  // 최초 진입 시 출력할 스타팅 포즈
  UPROPERTY(EditAnywhere, Category = AAPlayerStart)
  TSubclassOf<class UAblAbility> SpawnAbl;
  
  // 캠프 간 이동 시 출력할 애니메이션 
  UPROPERTY(EditAnywhere, Category = AAPlayerStart)
  TSubclassOf<class UAblAbility> TeleportAbl;
};

활성화와 활성화 이후 상호작용

  • 체크포인트는 첫번째 상호작용 시에는 활성화 하고, 두번째 상호작용부터는 레벨 이동 등 기능을 지원해야 했다
  • 이 부분이 골치아팠던 것이, 기본적으로 NPC가 아닌, 레벨 액터의 상호작용 시스템은 1회성으로 디자인 되어 있었다
  • 그래서 그냥 토글로 활성화 / 비활성화 상태를 나눈 뒤 조건을 체크해서 대응하는 상호작용 로직을 타도록 디자인했다
    • 구조를 잡는 게 나았을 텐데, 2단계보다 더 많거나 그렇지도 않아서 그냥 이렇게 했다

텔레포트 시 카메라 이슈

bool ACampActor::TryTeleport(APlayerController* PlayerController)
{
  if (IsValid(PlayerController) == false || IsValid(PlayerController->GetPawn()) == false)
  {
    return false;
  }

  const int32 PlayerID = AAFL::GetPlayerID(PlayerController);

  if (ACustomALSCharacter* PlayerCharacter = Cast<ACustomALSCharacter>(PlayerController->GetPawn())
    ; PlayerStarts.Contains(PlayerID) && IsValid(PlayerStarts[PlayerID]))
  {
    PlayerCharacter->TeleportTo(PlayerStarts[PlayerID]->GetPlayerStart()->GetTargetLocation(), PlayerStarts[PlayerID]->GetPlayerStart()->GetActorRotation());
    PlayerCharacter->GetMovementComponent()->StopMovementImmediately();
    
    // 캠프 간 이동 시 이동 Abl 재생
    PlayerStarts[PlayerID]->PlayAbl(PlayerCharacter, PlayerStarts[PlayerID]->GetTeleportAbl());
    
    // 카메라도 이동
    ACustomALSPlayerCameraManager* PlayerCameraManager = Cast<ACustomALSPlayerCameraManager>(PlayerController->PlayerCameraManager); 
    check(PlayerCameraManager);

    PlayerCameraManager->TeleportCamera(false);

    return true;
  }

  return false;
}
  • 카메라 피벗 이동 시, 실제 카메라는 래깅을 주면서 피벗을 추적하는 로직으로 작동했다
  • 그래서 플레이어가 텔레포트를 하는 등 순간적으로 먼 위치를 이동하면, 카메라 Position도 같이 변하도록 별도의 작업이 필요했다
void ACustomALSPlayerCameraManager::TeleportCamera(bool bRotate)
{
  // 플레이어 캐릭터가 바라보는 방향으로 카메라 방향도 수정 필요
  if (bRotate)
  {
    EnableCameraRotationUpdate(false);
    const FTransform& PivotTarget = ControlledCharacter->GetThirdPersonPivotTarget();

    // 임시 저장해둔 TargetCameraRotation도 업데이트
    CameraBuffer.TargetCameraRotation = PivotTarget.GetRotation().Rotator();
  }

  // 현재 카메라 POV 받아오기
  FMinimalViewInfo POV;
  CustomCameraBehavior(10.f, POV.Location, POV.Rotation, POV.FOV);
  
  // Location과 Rotation만 업데이트
  POV.Location = TargetCameraLocation;
  POV.Rotation = TargetCameraRotation;
  SetCameraCachePOV(POV);
  
  if (bRotate)
  {
    EnableCameraRotationUpdate(true);
  }
}

Note

Checkpoint Component 같은 걸 만들어서 해당 위치로의 스폰, 이동, 저장 및 로드 등의 기능을 구현했어야 했다.
체크포인트 역할을 하는 액터가 하나였기에 망정이지, 같은 기능을 하는 액터가 다양하게 기획되었다면 끔찍했을 것…