
네...? 제가여...? PlayerStart를여...???
언리얼 엔진으로 플랫포머 + 맵에디팅 기능이 포함된 멀티플레이 게임 개발 프로젝트를 진행했다.
나의 담당 역할은 바로 게임 내 Prop(배치되는 모든 것들)을 구현하는 것!
이때, 플레이어 "시작 Prop"... 즉, PlayerStart를 수동으로 만들어야했다.
그때의 우여곡절이 떠올라서 기록해 놓는 문서
이 글은 언리얼 엔진 5.6 기준으로 작성되었지만, 당시 구현한 Prop은 5.5 기준으로 제작되었습니다.
사실 초기 아이디어 단계는 비교적 수월했다. 언리얼엔진 에디터를 보면

이 처럼 캐릭터의 시작 위치를 PlayerStart / Camera Location 둘중 하나 골라서 택할 수 있다.
이걸 보고, 플레이어의 시작 위치를 결정하는 함수가 있을거라고 생각하고 공식 문서 - Player Start Actor를 읽어보니, FindPlayerStart() 와 ChoosePlayerStart() 함수에 대해 설명되어 있었다.
문서를 참고해 두 함수에 대해 정리해보면,
FindPlayerStart()호출 시점: 엔진이 플레이어의 시작 위치를 필요로 할 때 자동으로 호출
동작
APlayerStart를 찾아 반환 (하지만 엔진이 내부적으로 호출할 때는 보통 이 파라미터를 제공하지 않는다.)사용 용도
ChoosePlayerStart()호출 시점: FindPlayerStart()가 새로운 위치를 골라야 할 때 호출
기본 구현
APlayerStart 액터들 중에서 랜덤으로 하나를 선택사용 용도
👉 정리하면,
FindPlayerStart()는 더 상위 레벨의 함수로, 기존 스폰 Actor가 없을 때 ChoosePlayerStart()를 호출함.FindPlayerStart() override.ChoosePlayerStart() override.나는 게임이 시작될 때, 최초 스폰 방식을 바꾸고 싶었기 때문에 Map의 GameMode에서 ChoosePlayerStart()를 override해 구현하였다.
ChoosePlayerStart()를 override 해보면, 스폰 위치로 사용할 AActor를 반환값으로 사용한다.
AActor* AMapGameMode::ChoosePlayerStart_Implementation(AController* Player)
나는 StartProp에 StartPoint(USceneComponent)를 두고, StartPoint값을 반환하는 함수를 만들었다.
FTransform AGameStartProp::PlayerStartTransform()
{
PlayerIdx %= StartPoints.Num();
// 스타트 포인트 값을 반환
return StartPoints[PlayerIdx++]->GetComponentTransform();
}
그러면 자연스럽게
AActor* AMapGameMode::ChoosePlayerStart_Implementation(AController* Player)
{
// 태그로 찾기
TArray<AActor*> FoundProps;
UGameplayStatics::GetAllActorsWithTag(this, FName("GameStart"), FoundProps);
AGameStartProp* Prop = Cast<AGameStartProp>(FoundProps[0]);
APlayerStart* TempStart = GetWorld()->SpawnActor<APlayerStart>(APlayerStart::StaticClass(), Prop->PlayerStartTransform());
return TempStart;
}
맵에 배치된 StartProp의 여러 StartPoint를 순회하여, 매 호출마다 그 위치에 APlayerStart를 동적 스폰하고 반환하게 된다.

즉, 맵이 시작하면 Point마다 캐릭터들이 스폰된다.
성공!

... 이 아니었다.
이 방식은 StartProp이 미리 맵에 배치되어있고, 플레이어가 직접 배치할 일이 없다면 문제가 되지 않는다.
처음 제작했을때, StartProp은 언제나 맵에 배치되어 있고 플레이어는 맵 에디터에서 StartProp을 제외한 나머지 Prop들만 배치 가능한 형태였다. 즉, 시작점은 고정된 형태!

하지만 확장 가능성과 자유도를 고려해 StartProp도 배치 가능 (맵과 함께 Load)한 형태로 변경했을 때 호출 순서로 인해 문제가 발생했다.
AGameModeBase::Login→PostLogin(NewPlayer)HandleStartingNewPlayer→RestartPlayer(NewPlayer)FindPlayerStart(NewPlayer)→ChoosePlayerStart(NewPlayer)SpawnDefaultPawnFor(NewPlayer, StartSpot)→Possess
기존의 ChoosePlayerStart_Implementation는 맵에 배치된(존재하는) StartProp을 전제로 동작했다. 지금은 StartProp을 맵 로딩 도중에 동적으로 생성하므로, 엔진이 처음 스폰을 시도하는 시점에는 StartProp은 아직 월드에 없게 된다.

결과적으로 StartProp이 없을 때 ChoosePlayerStart가 호출되어 원하는 Transform을 얻지 못하고, 기본 로직(랜덤 PlayerStart)이 실행되거나 StartPoint가 Null이 되는 문제가 발생하는 것이다.
즉, 엔진 스폰 시도 시점 vs Prop 생성 완료 시점의 순서 불일치가 문제 발생의 원인.
일단 문제는 맵이 로딩이 되지 않았기 때문에, PlayerStart가 없었고 이때 ChoosePlayerStart가 호출 된 것이다. 이를 해결하기 위해서
- 맵이 로딩 될 때까지 기다리기
- 로딩이 완료되었을때
ChoosePlayerStart를 호출해서 StartPoint를 얻고RestartPlayerAtPlayerStart를 호출해서 리스폰하기
다음과 같이 설계했다.
쉽게 말하면 캐릭터 스폰을 맵 준비 완료 뒤로 미뤘다.

일단 스폰하는 단계(SpawnDefaultPawnFor)에서 맵 로딩을 기다리게 했다.
APawn* AInGameMode::SpawnDefaultPawnFor_Implementation(AController* NewPlayer, AActor* StartSpot)
{
if (!bIsMapReady) // 맵이 로드 되지 않았다면
{
// 로그만 남기고 스폰 보류
if (!PendingPlayers.Contains(NewPlayer))
{
PendingPlayers.Add(NewPlayer);
}
return nullptr;
}
PendingPlayers에 NewPlayer를 추가한 후에, 엔진의 초기 스폰 시도를 중단한다.

void AInGameMode::OnMapLoaded()
{
bIsMapReady = true; // 로딩 완료
for (AController* Controller : PendingPlayers)
{
AActor* Actor = ChoosePlayerStart(Controller);
RestartPlayerAtPlayerStart(Controller, Actor); // 다시 스폰 시도
}
PendingPlayers.Empty();
}
이전과 같은 방식으로 스폰이 이루어진다.
무사히 스폰되었다!
진짜 성공!
ChoosePlayerStart에 구현하지 않은 이유
ChoosePlayerStart는 어디에 스폰할지 선택 해서 AActor*를 반환하는 함수다. ChoosePlayerStart는 반드시 유효한 StartSpot(AActor*)을 요구하기 때문에 이 함수에서 nullptr을 반환하면 엔진은 다시 FindPlayerStart로 폴백하거나, 최악의 경우 잘못된 기본 위치로 스폰을 시도한다. “보류”라는 의도가 엔진에 전달되지 않는다.