2-9강 무한 맵의 제작

Ryan Ham·2024년 7월 6일
0

이득우 Unreal

목록 보기
14/23
post-thumbnail

강의 목표

  • 무한 맵 생성을 위한 기믹 액터의 설계
  • 애셋 매니저를 활용한 애셋 관리 방법의 학습
  • 액터 스폰과 약참조 포인터의 사용 실습

스테이지 기믹의 설계

구현해야 할 기능들

  • Stage에 설치한 Trigger Volume의 감지 처리
  • 각 문에 설치한 4개의 Trigger Volume의 감지 처리
  • 상태별로 설정할 문의 회전 설정
  • 대전할 NPC의 spawn 기능
  • 아이템 상자의 spawn 기능
  • 다음 stage의 spawn 기능
  • NPC의 죽음 감지 기능
  • 아이템 상자의 overlap 감지

=> 정리해보면 총 5개(메인 1개 , 문마다 1개씩)의 Trigger Volume, 3개의 Spawn(NPC, ItemBox, Stage), 문 회전, NPC 죽음, 상자와 player의 overlap 기능을 구현해보자.

Stage Gimmick안에서 State 종류

Ready : Player의 입장을 처리하는 단계

  • Stage 중앙에 위치한 Trigger volume을 준비
  • Player와 Trigger Volume 간에 Overlap event가 발생하면 다음 단계로 전환

Fight : Player와 NPC가 대전하는 단계

  • Player가 못 나가게 stage의 모든 문을 닫고 대전할 NPC를 스폰
  • NPC를 처리하면 다음 단계로 이동

Reward : Player가 보상을 선택하는 단계

  • 정해진 위치의 4개의 상자에서 아이템을 랜덤하게 생성
  • 상자를 선택하면 다음 단계로 이동

Next : 다음 Stage로 이동을 처리하는 단계

  • 스테이지의 문을 개방
  • 문에 설치된 trigger volume을 활용해 player의 overlap event가 발동하면 통과하는 문에 새로운 stage를 이어서 생성

Ready -> Fight -> Reward -> Next가 무한히 순환하는 구조

// StageGimmick.h
UENUM(BlueprintType)
enum class EStageState : uint8
{
	READY = 0,
	FIGHT,
	REWARD,
	NEXT
};

이 상태는 우리가 열거형으로 선언을 한다.

중요! 게임에서 이렇게 단계의 구성은 state로 관리를 하는데, 모든 게임에서 단계의 전환은 항상 있을 것이므로 이 방법을 잘 숙지하고 나중에 무조건 적용해보자!

Stage Trigger 설정

// StageGimmick.h
// Stage Section

	StageTrigger->SetCollisionProfileName(TEXT("RyanTrigger"));
	StageTrigger->OnComponentBeginOverlap.AddDynamic(this, &ARyanStageGimmick::OnStageTriggerBeginOverlap);

이제는 많이 봐와서 익숙하겠지만 Stage에는 Pawn에 대해서만 Overlap event를 탐지하는 Collision Profile을 적용하고, Overlap event가 발생했을때는 delegate를 사용해서 등록된 함수 OnStageTriggerBeginOverlap을 발동하게 만든다.

Gate 설정

Gates는 TMap<FName, TObjectPtr<class UStaticMeshComponent>>
GateTrigger는 TArray<TObjectPtr<class UBoxComponent>>

Gate쪽 로직을 구현할 때에는 총 4개의 gate들과 그에 해당하는 4개의 Trigger Box를 구현해주어야 한다.

Gate의 StaticMesh에서의 Socket Manager를 살펴보면 총 8개의 socket이 존재한다. Gate가 부착될 +XGate, -XGate, +YGate, -YGate socket과 이어질 Stage가 생성될 포인트인 +X, -X, +Y, -Y socket.

Gate와 캐릭터 OverLap시 새로운 Gimmick 붙이기

void ARyanStageGimmick::OnGateTriggerBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
	check(OverlappedComponent->ComponentTags.Num() == 1);
	FName ComponentTag = OverlappedComponent->ComponentTags[0];
	FName SocketName = FName(*ComponentTag.ToString().Left(2));
	check(Stage->DoesSocketExist(SocketName));

	FVector NewLocation = Stage->GetSocketLocation(SocketName);
	TArray<FOverlapResult> OverlapResults;
	FCollisionQueryParams CollisionQueryParam(SCENE_QUERY_STAT(GateTrigger), false, this);
	bool bResult = GetWorld()->OverlapMultiByObjectType(
		OverlapResults,
		NewLocation,
		FQuat::Identity,
		FCollisionObjectQueryParams::InitType::AllObjects,
		FCollisionShape::MakeSphere(775.0f),
		CollisionQueryParam
	);

	if (!bResult)
	{
		GetWorld()->SpawnActor<ARyanStageGimmick>(NewLocation, FRotator::ZeroRotator);
	}
}

캐릭터가 다음 Stage로 가려고 할때 새로운 Gimmick Stage를 붙이는 코드이다. Gate에 존재하는 Trigger Box와 OverLap시 이에 해당하는 OverLap Delegate에 인자로 넘겨질 OnGateTriggerBeginOverlap 함수에 대한 로직을 위 코드 snippet에서 확인할 수 있다.

여기서 생성하려고 하는 위치에 이미 Stage Gimmick가 있는데 다시 생성하면 안되므로 OverlapMultiByObjectType으로 생성 위치에 collision 검사를 하고 결과가 null인 경우에만 새로운 Stage Gimmick을 생성하도록 한다.

Delegate를 통한 State 전환

Switch-Case가 아닌 Delegate 배열로 관리

Switch-Case문으로 상태 변경에 대한 대응을 할 수도 있지만, 이는 코드의 가독성을 떨어뜨릴 위험이 있다. 앞 강에서 상자의 아이템 효과를 캐릭터에 적용할 때 사용했던 Delegate를 구조체에 감싸 컨테이너(이번에는 TMap!)에 저장하는 형식으로 State 전환 또한 똑같이 디자인 해보자.

Step 1 : Delegate 구조체 만들기

// StageGimmick.h
// Custom Delegate 선언
DECLARE_DELEGATE(FOnStageChangedDelegate);

// Delegate Array에 들어갈 Delegate을 wrapping하는 구조체 선언
USTRUCT(BlueprintType)
struct FStageChangedDelegateWrapper
{
	GENERATED_BODY()
	FStageChangedDelegateWrapper() { }
	FStageChangedDelegateWrapper(const FOnStageChangedDelegate& InDelegate) : StageDelegate(InDelegate) {}
	FOnStageChangedDelegate StageDelegate;
};

Step 2 : TMap에 Delegate Wrapper 추가하기

// StageGimmick.h
protected:
	UPROPERTY()
	TMap<EStageState, FStageChangedDelegateWrapper> StateChangeActions;

TMap 타입으로 된 StageChangeActions라는 변수 선언.

// StageGimmick.cpp
// Delegate가 들어가있는 구조체를 배열에 차례대호 삽입.
CurrentState = EStageState::READY;
StateChangeActions.Add(EStageState::READY, FStageChangedDelegateWrapper(FOnStageChangedDelegate::CreateUObject(this, &ARyanStageGimmick::SetReady)));
StateChangeActions.Add(EStageState::FIGHT, FStageChangedDelegateWrapper(FOnStageChangedDelegate::CreateUObject(this, &ARyanStageGimmick::SetFight)));
StateChangeActions.Add(EStageState::REWARD, FStageChangedDelegateWrapper(FOnStageChangedDelegate::CreateUObject(this, &ARyanStageGimmick::SetChooseReward)));
StateChangeActions.Add(EStageState::NEXT, FStageChangedDelegateWrapper(FOnStageChangedDelegate::CreateUObject(this, &ARyanStageGimmick::SetChooseNext)));

8강에서도 보았듯이 순수 Delegate는 배열에 바로 넣을 수 없으므로 구조체로 Delegate를 한번 감싼 다음에 배열에 추가하는 모습.

Step 3 : Delegate에 bound된 함수 실행

// StageGimmick.cpp
void ARyanStageGimmick::SetState(EStageState InNewState)
{
	CurrentState = InNewState;

	// Contains는 TMap에서 parameter로 들어온 key값을 찾는다
	if (StateChangeActions.Contains(InNewState))
	{
		// Bracket안에 key 값을 넣으면 key 값에 해당되는 value를 return한다. 
		StateChangeActions[CurrentState].StageDelegate.ExecuteIfBound();
	}
}

SetState 함수를 위와 같이 구현해 주고, 코드 내에서 State가 바뀔때마다 이 SetState 함수에 현재 State 값을 Enum형태로 넣어서 call 한다.

Step 1 ~ Step 3을 통해 우리는 State를 관리하는 비밀이 Delegate에 있는 것을 알았다! Delegate은 정말 여러 곳에서 쓰이니 사용방법을 잘 알아두자:D

각 상태(READY, FIGHT, REWARD, NEXT)의 로직

void ARyanStageGimmick::SetReady()
{
	StageTrigger->SetCollisionProfileName(CPROFILE_RyanTRIGGER);
	for (auto GateTrigger : GateTriggers)
	{
		GateTrigger->SetCollisionProfileName(TEXT("NoCollision"));
	}

	OpenAllGates();
}

void ARyanStageGimmick::SetFight()
{
	StageTrigger->SetCollisionProfileName(TEXT("NoCollision"));
	for (auto GateTrigger : GateTriggers)
	{
		GateTrigger->SetCollisionProfileName(TEXT("NoCollision"));
	}

	CloseAllGates();

	GetWorld()->GetTimerManager().SetTimer(OpponentTimerHandle, this, &ARyanStageGimmick::OnOpponentSpawn, OpponentSpawnTime, false);
}

void ARyanStageGimmick::SetChooseReward()
{
	StageTrigger->SetCollisionProfileName(TEXT("NoCollision"));
	for (auto GateTrigger : GateTriggers)
	{
		GateTrigger->SetCollisionProfileName(TEXT("NoCollision"));
	}

	CloseAllGates();
	SpawnRewardBoxes();
}

void ARyanStageGimmick::SetChooseNext()
{
	StageTrigger->SetCollisionProfileName(TEXT("NoCollision"));
	for (auto GateTrigger : GateTriggers)
	{
		GateTrigger->SetCollisionProfileName(CPROFILE_RyanTRIGGER);
	}

	OpenAllGates();
}

OnConstruction

OnConstruction이 불리는 시점

OnConstruction 함수는 언리얼 엔진에서 AActor 클래스의 가상 함수로, 액터가 생성되거나 에디터에서 속성이 변경될 때 호출된다. 이 함수는 액터의 초기 설정이나 속성 변경 시 필요한 로직을 실행하는 데 유용하며, 에디터와 런타임 모두에서 일관된 초기화 작업을 수행할 수 있게 한다. 이를 통해 액터의 상태가 변경될 때마다 자동으로 필요한 업데이트 작업을 처리할 수 있다.

Level Design을 할때 OnConstruction을 사용하면 Level에 배치된 Object의 property를 editor에서 바꿀때 이를 바로 화면에서 확인할 수 있다.

위와 같이 Actor의 property가 editor에서 변경되면 OnConstruction이 불리게 되고 이를 viewport 상에서 바로바로 확인할 수 있다.

OnConstruction 함수 override하기

// StageGimmick.cpp
void ARyanStageGimmick::OnConstruction(const FTransform& Transform)
{
	Super::OnConstruction(Transform);

	SetState(CurrentState);
}

OnConstruction을 사용하기 위해 위와 같이 override 한다. OnConstruction은 함수 인자로 Transform을 받게 되는데 이는 Transform의 변경 뿐만 아니라 모든 속성이 변경될 때 자동으로 호출되게 된다. 따라서, 여기 안에 SetState를 넣으면 Editor에서 변경이 일어날때마다 이 함수가 불리게 된다.

정말 이를 적극적으로 사용하면 레벨 디자인 할때 굉장히 편하게 사용할 수 있다!


TSubclassOf이란?

TSubclassOf는 언리얼 엔진에서 특정 클래스의 서브클래스를 가리키는 템플릿 클래스이다. 이는 주로 UClass 포인터를 안전하게 wrapping하여, 특정 클래스 유형과 그 서브클래스를 참조할 수 있도록 한다. 이를 통해 클래스 타입의 변수나 프로퍼티를 선언할 때, 해당 변수나 프로퍼티가 특정 클래스의 서브클래스만 가질 수 있도록 제한할 수 있으며, 런타임 또는 에디터에서 클래스 타입을 안전하게 선택하고 사용하게 한다. 예를 들어, TSubclassOf<AActor>는 AActor의 서브클래스만 참조할 수 있다.

TSubclassOf의 선언

// StageGimmick.h
TSubclassOf<class ARyanNPCCharacter> OpponentClass;

NPC Spawn

이번에 사용할 NPC!

// StageGimmick.cpp
// NPC Spawn
void ARyanStageGimmick::OnOpponentSpawn()
{
	//const FVector SpawnLocation = GetActorLocation() + FVector::UpVector * 88.0f;
	const FVector SpawnLocation = GetActorLocation();
	// 지정한  Class, 위치, 회전 값을 넘겨주어서 생성시킬 몬스터를 spawn한다. 
	AActor* OpponentActor = GetWorld()->SpawnActor(OpponentClass, &SpawnLocation, &FRotator::ZeroRotator);
	ARyanNPCCharacter* RyanOpponentCharacter = Cast<ARyanNPCCharacter>(OpponentActor);
	if (RyanOpponentCharacter)
	{
		RyanOpponentCharacter->OnDestroyed.AddDynamic(this, &ARyanStageGimmick::OnOpponentDestroyed);
	}
}

GetWorld() -> SpawnActor()를 사용해서 NPC를 Spawn. 캐릭터를 생성하면서 OnOpponentDestroyed함수를 OnDestroyed delegate에 등록해준다.

// StageGimmick.cpp
// 함수 실행
void ARyanStageGimmick::SetFight()
{
	...

	CloseAllGates();

	// 언리얼의 TimeManager를 사용해서 일정 시간이 지나면 NPC 캐릭터 Spawn
	GetWorld()->GetTimerManager().SetTimer(OpponentTimerHandle, this, &ARyanStageGimmick::OnOpponentSpawn, OpponentSpawnTime, false);
}

언리얼의 TimeManager를 통해 Fight state로 transition 되었을때 일정 시간 후 NPC를 spawn하게 한다.


Asset Manager

상자 안에 들어있는 아이템을 수동으로 설정하는 것이 아닌 자동으로 랜덤 할당하게 해보자. 이를 위해서는 언리얼에서 제공하는 Asset Manager를 사용해보자.

Asset Manager의 특징은 다음과 같다.

  • Unreal Engine이 제공하는 Asset을 관리하는 Singleton Class
  • 엔진이 초기화 될때 제공되며, Asset 정보를 요청해 받을 수 있음.
  • PrimaryAssetId를 사용해 Project 내 Asset의 주소를 얻어올 수 있다.
  • PrimaryAssetId는 태그와 이름의 2가지 키 조합으로 구성되어 있다.
  • 특정 Tag를 가진 모든 Asset 목록을 가져올 수 있다.

지금까지는 레퍼런스 복사를 통해서 asset을 로딩했는데 PrimaryAssetId를 통한 Asset Manager로 asset을 로딩할 수도 있다.

일단 Weapon, Potion, Scroll 아이템은 다 같은 클래스를 상속한다. 따라서 Asset Manager에서 해당 클래스를 등록하고 이후, Item.h 파일에서 해당 클래스가 Asset Manager에서 어떤 Asset Id값을 가지는지 명시시켜준다.

AssetId 설정

// ItemData.h
public:
	FPrimaryAssetId GetPrimaryAssetId() const override
	{
		return FPrimaryAssetId("RyanItemData", GetFName());
	}

유일한 식별자 아이디 값은 "Tag 이름"과 "GetFName을 사용한 에셋의 이름" 2가지 정보를 사용해서 결정된다.

이 기능을 확장한다면 서로 다른 클래스에도 FPrimaryAssetId를 통해 같은 tag를 부여한다면 AssetManager입장에서는 같은 종류의 Asset으로 인지할 것이다.

Asset Manager 로드

// ItemBox.cpp

void ARyanItemBox::PostInitializeComponents()
{
	Super::PostInitializeComponents();

	UAssetManager& Manager = UAssetManager::Get();

	TArray<FPrimaryAssetId> Assets;
	Manager.GetPrimaryAssetIdList(TEXT("RyanItemData"), Assets);
	ensure(0 < Assets.Num());

	int32 RandomIndex = FMath::RandRange(0, Assets.Num() - 1);
	FSoftObjectPtr AssetPtr(Manager.GetPrimaryAssetPath(Assets[RandomIndex]));
	if (AssetPtr.IsPending())
	{
		AssetPtr.LoadSynchronous();
	}
	Item = Cast<URyanItemData>(AssetPtr.Get());
	ensure(Item);
}

Actor의 세팅이 마무리되는 시점에 호출되는 함수인 PostInitializeComponents()를 override한다. 이후, 함수 안에서 Asset Manager를 가져온다. Asset Manager는 엔진이 초기화될때 언제나 로딩이 보장되므로 순서상으로 문제가 없다.

아까 PrimaryAssetId로 설정한 Asset들을 GetPrimaryAssetIdList를 통해 리스트 형태로 쫙 불러오고, Random 함수를 사용해서 Soft Referencing으로 이들을 상자안에 하나씩 넣는다.


최종 화면

시작 stage에는 차별성을 두기 위해서 불상을 가운데에 배치하였다. State의 전환서부터 Gimmick의 작동까지 원활하게 작동하는 것을 확인할 수 있다.


기타

언리얼의 좌표 시스템

언리얼의 좌표 시스템은 일반적인 오른손 좌표계가 아닌 왼손 좌표계이다. 따라서 외적의 방향을 구할때에도 오른손 법칙을 쓰는 것이 아닌 왼손 법칙을 사용해서 올바른 외적 벡터의 방향을 구할 수 있다.

언리얼의 X,Y,Z 축

X-axis => Forward 방향. FVector::ForwardVector으로 표현
Y-axis => Right 방향. FVector::RightVector으로 표현
Z-axis => Up 방향. FVector::UpVector으로 표현

FName과 FString의 차이점

FName은 변경할 수 없는 고정 문자열로, 주로 성능이 중요한 상황에서 빠른 문자열 비교와 해시 테이블 조회를 위해 사용되며, 메모리를 효율적으로 관리한다.

반면, FString은 동적으로 크기를 변경할 수 있는 가변 문자열로, 다양한 문자열 조작이 가능하고, 주로 텍스트 데이터나 파일 경로 등 자주 변경되거나 조작이 필요한 문자열에 사용된다.

profile
🏦KAIST EE | 🏦SNU AI(빅데이터 핀테크 전문가 과정) | 📙CryptoHipsters 저자

0개의 댓글