본문 바로가기

개인 프로젝트(과제)

8번과제 맵 만들기 - 레벨 1

지금까지 필수 과제 로직을 구현했다면 이제 게임이 실행될 맵을 만들어야 한다.

우선 내가 생각한 맵은 많이 부족하지만 폴가이즈 맵을 따라서 느낌만 비슷하게 구현해 볼 예정이다.

 

레벨 1 = 문으로 돌진

출처 : https://gameinforword.tistory.com/85


레벨 1

Fab에서 풀 머티리얼과 벽돌 머티리얼을 받아서 랜드스케이프 모드로 생성한 땅에 풀 머티리얼을 적용하고 벽으로 사용할 스태틱 메시에 벽돌 머티리얼을 적용했다.

문으로 돌진처럼 내리막길에 벽에 세 개의 문이 있는데 게임이 실행될 때 마다 랜덤하게 통과가 되는 문이 한 개 있다.

도착 지점에 트로피를 둬서 근처에 가면 다음 레벨로 이동하도록 구현을 하려한다.

랜덤으로 통과되는 문 구현하기

레벨 블루프린트에서 랜덤한 1부터 100까지의 정수 값을 받아와서 변수에 저장하고 그 값을 33씩 나눠서 하나의 문만 통과가 되도록 구현을 했다.

처음에 구현할 때는 벽을 여러 개 만들면 만든 벽마다 위 로직을 모두 구현해야 한다는 생각에 막막했지만 이 로직을 함수로 만들어서 벽 스태틱메시 오브젝트를 입력 파라미터로 받아서 변수로 만들고 이를 Set Collision Enable의 Target에 넣어줬더니 깔끔하고 간편하게 랜덤으로 통과하는 문이 구현이 됐다.

그리고 도착지점에 텍스트를 추가를 하면 좋을 것 같아서 텍스트를 넣는 액터가 있나 검색해봤는데 Text Render Actor라는게 있어서 사용해봤다.

크기와 회전값을 적절히 회전해서 바닥에 GOAL 이라는 텍스트를 추가해봤다.

 

그리고 트로피 에셋을 다운 받아서 배치하고 근처에 Trigger Box를 배치했다.

Trigger Box에 JinCharacter가 겹쳐지면 JinGameState에서 구현해놓은 EndLevel 함수를 리플렉션 시스템을 통해 노출된 함수를 호출하도록 구현했다.

// JinGameState.h
	UFUNCTION(BlueprintCallable, Category = "Level")
	void EndLevel();

이렇게 구현하면서 C++ 코드를 조금 수정했다.

수정 전 : 레벨이 시작될 때 SetTimer로 LevelDuration초가 지나면 OnLevelTimeUp 함수가 호출되어 다음 레벨로 넘어감.

수정 후 : 제한시간 안에 다음 레벨로 넘어가지 못하면 바로 GameOver가 되게끔 OnGameOver 함수를 호출하도록 수정함.

// JinGameState.cpp
// StartLevel 함수
	GetWorldTimerManager().SetTimer(
		LevelTimerHandle,
		this,
		&AJinGameState::OnGameOver, // OnLevelTimeUP에서 수정
		LevelDuration,
		false
	);

그리고 코인을 모두 모으면 다음 레벨로 넘어가는 로직을 삭제하고 트로피 근처에 갈 때만 넘어가도록 수정했다.

// JinGameState.cpp
void AJinGameState::OnCoinCollected()
{
	++CollectedCoinCount;
	
	RemainCoin = SpawnedCoinCount - CollectedCoinCount;
	/*if (SpawnedCoinCount > 0 && CollectedCoinCount >= SpawnedCoinCount)
	{
		EndLevel();
	}*/
}

이제 랜덤한 통과되는 문과 트로피 근처로 가면 다음 레벨로 이동하도록 구현했는데 각 문 사이에 아이템을 스폰 시켜야 한다.

현재 맵에 배치된 SpawnVolum은 시작위치부터 문 사이마다 총 4개를 배치했다.

기존에 배치된 SpawnVolume을 배열에 담아서 그 원소를 ASpawnVolume*에 캐스트를 해서 그 SpawnVolume에 ItemToSpawn만큼 아이템 개수가 스폰되는 로직이 있었는데 그 로직을 조금 수정해서 ItemToSpawn을 배치된 SpawnVolume의 수만큼 나눠서 아이템 개수가 동일하게 배분 되도록 로직을 구현했다.

// JinGameState.cpp
// StartLevel 함수
	TArray<AActor*> FoundVolumes;
	UGameplayStatics::GetAllActorsOfClass(GetWorld(), ASpawnVolume::StaticClass(), FoundVolumes);
	
	int32 size = FoundVolumes.Num(); // 맵에 배치된 SpawnVolume 수
	ItemToSpawn = 40 + (CurrentLevelIndex * 10); // 1레벨 40개, 레벨 증가하면 10개씩 증가
	SpawnedActors.Reserve(ItemToSpawn); // 미리 배열 크기 할당해놓기(재할당 안일어나게)

	if (size > 0) // SpawnVolume이 있으면 실행
	{
		for(int32 Volume = 0; Volume < size; ++Volume) // 배치된 SpawnVolume중 어떤 SpawnVolume에 소환할건지
		{
			ASpawnVolume* SpawnVolume = Cast<ASpawnVolume>(FoundVolumes[Volume]);

			if (SpawnVolume)
			{
				for (int32 i = 0; i < ItemToSpawn / size; ++i) // ItemToSpawn / size로 동일한 개수로 스폰
				{
					AActor* SpawnedActor = SpawnVolume->SpawnRandomItem();
					if (SpawnedActor)
					{
						SpawnedActors.Add(SpawnedActor); // 소환된 아이템 담기
						if (SpawnedActor->IsA(ACoinItem::StaticClass()))
						{
							++SpawnedCoinCount;
						}
					}
				}
			}
		}
	}

위처럼 구현을 하고 테스트를 해보니 아이템이 구역마다 동일 개수로 스폰은 잘 됐는데 size(맵에 배치된 스폰볼륨 개수)와 ItemToSpawn(레벨당 총 스폰될 아이템 개수)가 int형 이여서 나머지가 생기면 그 수 만큼 스폰이 되지 않는 경우가 발생했다.

ex) 레벨 2에서 ItemToSpawn이 50이고 맵에 배치한 SpawnVolume이 6개면 50 / 6 했을 때 나머지 2가 사라지고 스폰볼륨 당 8개의 아이템이 소환된다. 총 48개가 소환됨.

이를 해결하기 위해 아이템 개수를 더 늘려서 소환하도록 ItemToSpawn을 size의 배수만큼 올림처리를 하려한다.

(위 예시에서 ItemToSpawn을 size의 배수인 54로 올린다는 뜻)

정수의 나눗셈에서 나머지가 있을 때 숫자를 올려주는 FMath::DivideAndRoundUp 함수가 언리얼에서 이미 구현이 돼있었다.

사용법은 FMath::DivideAndRoundUp(num1, num2)를 하면 (num1 + num2 - 1) / num2의 값을 반환한다.

이 함수를 사용해서 ItemToSpawn = FMath::DivideAndRoundUp(40 + (CurrentLevelIndex * 10), size) * size; 으로 수정했다.

 

이제 문 사이마다 동일한 아이템 개수 스폰도 완료가 됐고 레벨 1에서 마지막으로 남은 문제는 아이템의 스폰 위치가 땅에 붙어있지 않고 땅 밑에서도 스폰이 되고 너무 높은 위치에도 스폰이 되는것이였다.

 

아이템 스폰 위치를 담당하는 함수에서 Z 값을 0으로 수정해봤다.

// SpawnVolume.cpp
FVector ASpawnVolume::GetRandomPointInVolume() const
{
	FVector BoxExtent = SpawningBox->GetScaledBoxExtent(); // 박스의 가로/세로/높이의 절반 길이를 반환한다
	FVector BoxOrigin = SpawningBox->GetComponentLocation(); // 박스의 중심 좌표

	return BoxOrigin + FVector(
		FMath::FRandRange(-BoxExtent.X, BoxExtent.X),
		FMath::FRandRange(-BoxExtent.Y, BoxExtent.Y),
		0 // FMath::FRandRange(-BoxExtent.Z, BoxExtent.Z)
	);
}

현재 내리막길을 따라 스폰볼륨을 기울여놨는데 위처럼 하니 박스의 중간 지점의 Z값을 받아와서 아이템이 사진기준으로 왼쪽(중간보다 올라간 부분)은 땅에 묻혀서 스폰되고 오른쪽(중간보다 내려간 부분)은 공중에 떠서 스폰이 된다.

아이템이 땅에 붙어서 스폰되도록 구현을 해야한다.

기울어진 땅을 따라서 아이템을 스폰하는 방법이 간단할 줄 알았는데 생각보다 많이 복잡해서 맵을 그냥 평지로 다시 만들까 생각을 했는데 GPT의 도움을 받았다. 하지만 GPT의 설명도 이해가 100% 되지 않아서 함수들이 어떤 의미이고 어떻게 작동을 하는지 한줄씩 이해하기 위해 공부를 했다.

GPT가 최종적으로 제안한 코드

// SpawnVolume.cpp
FVector ASpawnVolume::GetRandomPointInVolume() const
{
	const FVector BoxExtent = SpawningBox->GetScaledBoxExtent(); // 박스의 가로/세로/높이의 절반 길이를 반환한다
	const FTransform& BoxTransform = SpawningBox->GetComponentTransform(); // 박스의 위치 + 회전 + 크기

	FVector Local =  FVector( FMath::FRandRange(-BoxExtent.X, BoxExtent.X),	FMath::FRandRange(-BoxExtent.Y, BoxExtent.Y), FMath::FRandRange(-BoxExtent.Z, BoxExtent.Z));
	
	return BoxTransform.TransformPositionNoScale(Local);
}

AActor* ASpawnVolume::SpawnItem(TSubclassOf<AActor> ItemClass)
{
	if (!ItemClass) return nullptr;

	AActor* SpawnedActor = GetWorld()->SpawnActor<AActor>(
		ItemClass, // 스폰할 객체
		GetRandomPointInVolume(), // 위치 FVector
		SpawningBox->GetComponentRotation() // 회전 FRotator
	);
	return SpawnedActor;
}
GetScaledBoxExtent()
박스의 가로 세로 높이의 절반 길이를 월드 스케일까지 반영한 값으로 반환함.
예: 로컬에서 (50,50,50) 박스인데, 스케일이 (2,1,1)라면 → (100,50,50)

GetComponentTransform()
컴포넌트의 월드 트랜스폼(위치, 회전, 스케일)을 담은 FTransform을 반환함.
이 박스가 월드 좌표계에서 어떤 트랜스폼을 가지고 있는지 나타내는 전체 정보

FVector Local = FVector(...)
박스의 반길이만큼 - ~ + 범위를 줘서 박스 안에서 임의의 한 점(좌표)를 뽑는 것.
박스의 로컬 좌표가 담긴다.

TransformPositionNoScale()
주어진 로컬 좌표를 회전 + 위치만 적용해서 월드 좌표로 변환한다.(스케일은 무시 NoScale)

위에서 이미 GetScaledBoxExtent()로 스케일을 반영했는데 또 스케일을 반영한다면 좌표가 이상한 위치로 튄다.(직접 해봤다.)
이 함수로 월드 좌표에서의 실제 스폰 위치가 정해진다.

박스 안에서의 랜덤한 로컬 좌표를 TransformPositionNoScale 함수로 월드 좌표로 변환해주면 되는것이였다.

이렇게 하니 아이템이 기울어져서 스폰이 됐다.

 

모두 완성된 줄 알고 영상을 찍던 중 트로피 근처의 TriggerBox에서 점프를 하면(들어갔다 나갔다) 다음 레벨로 넘어가기 전에 Wait 하는 시간이 초기화가 돼서 계속 점프했더니 게임 오버가 됐다.

이를 고치기 위해 한번 들어가면 bool 변수를 만들어서 EndLevel 함수가 실행되고 bool 변수를 true로 바꾸고 false일때만 작동하도록 수정했다.

레벨 1 영상

 

아직 레벨 2, 3은 미구현이다.