[UE5] PlayerStart 만들기

kim skye·2025년 9월 12일

Unreal Engine

목록 보기
3/3

네...? 제가여...? PlayerStart를여...???

개요

언리얼 엔진으로 플랫포머 + 맵에디팅 기능이 포함된 멀티플레이 게임 개발 프로젝트를 진행했다.
나의 담당 역할은 바로 게임 내 Prop(배치되는 모든 것들)을 구현하는 것!

  • 이때, 플레이어 "시작 Prop"... 즉, PlayerStart를 수동으로 만들어야했다.

  • 그때의 우여곡절이 떠올라서 기록해 놓는 문서

이 글은 언리얼 엔진 5.6 기준으로 작성되었지만, 당시 구현한 Prop은 5.5 기준으로 제작되었습니다.


초기 설계

사실 초기 아이디어 단계는 비교적 수월했다. 언리얼엔진 에디터를 보면

이 처럼 캐릭터의 시작 위치를 PlayerStart / Camera Location 둘중 하나 골라서 택할 수 있다.
이걸 보고, 플레이어의 시작 위치를 결정하는 함수가 있을거라고 생각하고 공식 문서 - Player Start Actor를 읽어보니, FindPlayerStart()ChoosePlayerStart() 함수에 대해 설명되어 있었다.
문서를 참고해 두 함수에 대해 정리해보면,


1. FindPlayerStart()

  • 호출 시점: 엔진이 플레이어의 시작 위치를 필요로 할 때 자동으로 호출

  • 동작

    1. 이미 해당 플레이어에게 시작 Actor가 지정되어 있다면 그것을 반환
    2. 없다면 내부적으로 ChoosePlayerStart() 를 호출하여 새로운 시작 지점을 선택
    3. 선택적으로 Incoming Name(문자열)이 인자로 들어오면, Player Start Tag가 그 문자열과 일치하는 APlayerStart를 찾아 반환 (하지만 엔진이 내부적으로 호출할 때는 보통 이 파라미터를 제공하지 않는다.)
  • 사용 용도

    • 플레이어가 재시작할 때 다른 로직을 적용하고 싶을 때 적합
    • 예: 처음 스폰 위치는 기본 로직을 쓰되, 리스폰 시에는 플레이어 상태나 팀 상황에 따라 다른 위치를 주고 싶을 때.

2. ChoosePlayerStart()

  • 호출 시점: FindPlayerStart()가 새로운 위치를 골라야 할 때 호출

  • 기본 구현

    • APlayerStart 액터들 중에서 랜덤으로 하나를 선택
    • 단, 주변이 막혀있지 않은(Start 위치에 다른 블로킹 콜리전이 없는) 곳을 우선으로 한다.
  • 사용 용도

    • 플레이어가 처음 게임에 합류할 때 시작 위치를 고르는 로직을 커스터마이징하고 싶을 때 적합
    • 예: 팀 기반 게임에서 팀별 스폰 지점을 강제하거나, 랜덤이 아닌 특정 규칙(예: 가장 가까운 안전 지점)을 적용하고 싶을 때.

👉 정리하면,

  • 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)한 형태로 변경했을 때 호출 순서로 인해 문제가 발생했다.

  • 호출 순서 파악
    엔진 기본 흐름에서 플레이어가 접속하면 먼저 GameMode가 스폰을 시도한다. 일반적인 순서는 다음과 같다. (엔진 내부 소스 코드를 보면 확인할 수 있다!)
  1. AGameModeBase::LoginPostLogin(NewPlayer)
  2. HandleStartingNewPlayerRestartPlayer(NewPlayer)
  3. FindPlayerStart(NewPlayer)ChoosePlayerStart(NewPlayer)
  4. SpawnDefaultPawnFor(NewPlayer, StartSpot)Possess

기존의 ChoosePlayerStart_Implementation맵에 배치된(존재하는) StartProp을 전제로 동작했다. 지금은 StartProp을 맵 로딩 도중에 동적으로 생성하므로, 엔진이 처음 스폰을 시도하는 시점에는 StartProp은 아직 월드에 없게 된다.

결과적으로 StartProp이 없을 때 ChoosePlayerStart가 호출되어 원하는 Transform을 얻지 못하고, 기본 로직(랜덤 PlayerStart)이 실행되거나 StartPoint가 Null이 되는 문제가 발생하는 것이다.

즉, 엔진 스폰 시도 시점 vs Prop 생성 완료 시점의 순서 불일치가 문제 발생의 원인.


다시 구현해보자!

일단 문제는 맵이 로딩이 되지 않았기 때문에, PlayerStart가 없었고 이때 ChoosePlayerStart가 호출 된 것이다. 이를 해결하기 위해서

  1. 맵이 로딩 될 때까지 기다리기
  2. 로딩이 완료되었을때 ChoosePlayerStart를 호출해서 StartPoint를 얻고
  3. RestartPlayerAtPlayerStart 를 호출해서 리스폰하기

다음과 같이 설계했다.
쉽게 말하면 캐릭터 스폰을 맵 준비 완료 뒤로 미뤘다.

1. 맵 로딩 기다리기

일단 스폰하는 단계(SpawnDefaultPawnFor)에서 맵 로딩을 기다리게 했다.

APawn* AInGameMode::SpawnDefaultPawnFor_Implementation(AController* NewPlayer, AActor* StartSpot)
{
	if (!bIsMapReady) // 맵이 로드 되지 않았다면
	{
		// 로그만 남기고 스폰 보류
		if (!PendingPlayers.Contains(NewPlayer))
		{
			PendingPlayers.Add(NewPlayer);
		}
	
		return nullptr;
	}

PendingPlayersNewPlayer를 추가한 후에, 엔진의 초기 스폰 시도를 중단한다.

2. 맵 로딩 완료, 다시 스폰 시도

void AInGameMode::OnMapLoaded()
{
	bIsMapReady = true; // 로딩 완료
    
	for (AController* Controller : PendingPlayers)
	{
		AActor* Actor = ChoosePlayerStart(Controller);
		RestartPlayerAtPlayerStart(Controller, Actor); // 다시 스폰 시도
	}
	
	PendingPlayers.Empty();
}
  • 이 시점에는 맵이 로딩 되어, StartProp과 StartPoint가 이미 생성되어 있으므로 이전과 같은 방식으로 StartProp이 StartPoint을 반환할 수 있다.

3. 플레이어 스폰

이전과 같은 방식으로 스폰이 이루어진다.

무사히 스폰되었다!
진짜 성공!


참고

ChoosePlayerStart에 구현하지 않은 이유

  • ChoosePlayerStart는 어디에 스폰할지 선택 해서 AActor*를 반환하는 함수다. ChoosePlayerStart반드시 유효한 StartSpot(AActor*)을 요구하기 때문에 이 함수에서 nullptr을 반환하면 엔진은 다시 FindPlayerStart로 폴백하거나, 최악의 경우 잘못된 기본 위치로 스폰을 시도한다. “보류”라는 의도가 엔진에 전달되지 않는다.

마무리

  • PlayerStart를 배치해놓지 않고 직접 만드는 경우가 흔할지는 모르겠지만, 원하는 순간에 원하는 위치로 플레이어를 스폰할 때에도 이 경험이 유용하게 쓰일 수 있을 것 같다.
  • 디버깅만이 문제 해결의 길이다... 캐릭터를 스폰하는 동작하나로도 매우 많은 함수가 사용되기 때문에, 어디서 문제가 되는지 찾기가 매우 까다로웠다. 로그가 큰 도움이 되었다.

참고 자료

profile
고수는 많다

0개의 댓글