본문 바로가기

개발 공부 기록/UnrealEngine5

이득우의 언리얼 C++ 게임 개발의 정석 UE5로 따라잡기 - 11

개요

'이득우의 언리얼 C++ 게임 개발의 정석'의 12장 AI 컨트롤러와 비헤이비어 트리 내용을 바탕으로 하고 있습니다.

알라딘 책 구매 링크

 

이득우의 언리얼 C++ 게임 개발의 정석

언리얼 엔진 학습에 목말라하는 게임 개발자에게 단비 같은 언리얼 엔진 프로그래밍 책이다. 에픽게임즈 본사의 개발자 프로그램 언리얼 데브 그랜트를 수상한 저자의 책으로, 언리얼 엔진의

www.aladin.co.kr

 

본문

언리얼 엔진은 주 게임 모듈(Primary Game Module)을 사용해 게임 프로젝트의 로직을 관리한다.
그런데 주 게임 모듈 외에 다른 모듈을 게임 프로젝트에 추가하고 로직을 분리해서 관리할 수 있다.
언리얼 에디터는 C++ 프로젝트를 생성할 때 주 게임 모듈을 자동으로 생성해주지만, 추가 모듈을 생성하는 기능은 제공하지 않는다.

새로운 모듈을 추가하려면 언리얼 빌드 규칙을 이해하고, 이에 따라 폴더와 파일을 생성해야 한다.
추가 모듈 제작을 위해 필요한 요소는 다음과 같다.

  • 모듈 폴더와 빌드 설정 파일: 모듈 폴더와 모듈명으로 된 Build.cs 파일
  • 모듈의 정의 파일: 모듈명으로 된 .cpp 파일

현재 프로젝트의 Source 폴더 아래에 ArenaBattleSetting 이라는 폴더를 생성해주고 ArenaBattle.Target.cs와 ArenaBattleEditor.Target.cs에 ArenaBattleSetting을 추가해준다.

// ArenaBattle.Target.cs

using UnrealBuildTool;
using System.Collections.Generic;

public class ArenaBattleTarget : TargetRules
{
	public ArenaBattleTarget(TargetInfo Target) : base(Target)
	{
		Type = TargetType.Game;
		DefaultBuildSettings = BuildSettingsVersion.V5;
		IncludeOrderVersion = EngineIncludeOrderVersion.Unreal5_4;
		ExtraModuleNames.AddRange( new string[] { "ArenaBattle", "ArenaBattleSetting" });
	}
}
// ArenaBattleEditor.Target.cs

using UnrealBuildTool;
using System.Collections.Generic;

public class ArenaBattleEditorTarget : TargetRules
{
	public ArenaBattleEditorTarget(TargetInfo Target) : base(Target)
	{
		Type = TargetType.Editor;
		DefaultBuildSettings = BuildSettingsVersion.V5;
		IncludeOrderVersion = EngineIncludeOrderVersion.Unreal5_4;
        ExtraModuleNames.AddRange(new string[] { "ArenaBattle", "ArenaBattleSetting" });
    }
}

 

코드를 작성 후 빌드를 하면 새로운 모듈을 컴파일하고, 새로운 DLL 파일이 생성된다.

이제 언리얼 에디터에서 이 DLL 파일을 로딩하도록 명령하는 일이 남았다.

프로젝트 폴더에 있는 uproject 파일을 우클릭 후 메모장으로 편집을 열고 아래와 같이 수정해준다.

모듈 정보를 기입할 때 새로운 “ArenaBattleSetting” 모듈을 다른 모듈보다 먼저 로딩하도록 LoadingPhase값을 PreDefault로 설정하고 ArenaBattle 모듈이 ArenaBattleSetting 모듈에 대해 의존성을 가지도록 설정한다. 그러면 이제부터 ArenaBattleSetting 모듈이 항상 ArenaBattle 모듈보다 먼저 언리얼 에디터 프로세스에 올라간다.

// ArenaBattle.uproject

{
	"FileVersion": 3,
	"EngineAssociation": "5.4",
	"Category": "",
	"Description": "",
	"Modules": [
		{
			"Name": "ArenaBattleSetting",
			"Type": "Runtime",
			"LoadingPhase": "PreDefault",
			"AdditionalDependencies": [
				"CoreUObject"
			]
		},
		{
			"Name": "ArenaBattle",
			"Type": "Runtime",
			"LoadingPhase": "Default",
			"AdditionalDependencies": [
				"Engine",
				"UMG",
				"AIModule",
				"ArenaBattleSetting"
			]
		}
	],
	"Plugins": [
		{
			"Name": "ModelingToolsEditorMode",
			"Enabled": true,
			"TargetAllowList": [
				"Editor"
			]
		}
	]
}

 

새로운 모듈을 사용하여 Object 타입의 ABCharacterSetting이라는 클래스를 생성해준다.

 

ABCharacterSetting은 앞으로 사용할 캐릭터 애셋의 목록을 보관한다.
생성자 코드에 캐릭터 애셋을 코드로 지정할 수 있지만, 만일 애셋이 변경되면 코드를 다시 만들고 컴파일해야 한다.

언리얼 엔진은 오브젝트의 기본값을 유연하게 관리하도록 외부 INI 파일에서 기본 속성 값을 지정하는 기능을 제공한다.

애셋은 경로 정보만 알면 프로그램에서 이를 참조해 로딩할 수 있다.
이 애셋 경로 정보를 보관하기 위해 언리얼 엔진은 FSoftObjectPath라는 클래스를 제공한다.

언리얼 오브젝트가 기본값을 INI 파일에서 불러들이려면 UCLASS 매크로에 config 키워드를 추가해 여기에 불러들일 INI 파일의 이름을 지정하고, 불러들일 PROPERTY 속성에는 config 키워드를 선언해야 한다. 이렇게 선언하면 언리얼 엔진은 언리얼 오브젝트를 초기화할 때 해당 속성의 값을 INI 파일에서 읽어 설정한다.

// ABCharacterSetting.h

#pragma once

#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "ABCharacterSetting.generated.h"

/**
 * 
 */
UCLASS(config=ArenaBattle)
class ARENABATTLESETTING_API UABCharacterSetting : public UObject
{
	GENERATED_BODY()

public:
	UABCharacterSetting();

	UPROPERTY(config)
	TArray<FSoftObjectPath> CharacterAssets;
};

 

UCLASS 매크로 내 config 키워드에 있는 ArenaBattle이라는 설정으로 인해, 언리얼 엔진은 초기화 단계에서 Config 폴더에 위치한 DefaultArenaBattle.ini 파일을 읽어들여 ABCharacterSetting의 CharcterAssets 값을 설정한다.

예제에 준비되어 있던 파일을 프로젝트 폴더의 Config 폴더 내에 추가한다.

 

 

언리얼 엔진이 초기화되면 엔진 구동에 필요한 모듈이 순차적으로 로딩된다.
모듈이 로딩되면서 모듈은 자신에게 속한 모든 언리얼 오브젝트의 기본값을 지정해 생성해내는데, 이를 클래스 기본 객체라고 한다.
그래서 엔진이 초기화되면 모든 언리얼 오브젝트 클래스 기본 객체가 메모리에 올라간 상태가 된다.

이렇게 메모리에 올라간 클래스 기본 객체는 GetDefault 함수를 사용해 가져올 수 있다.
클래스 기본 객체는 엔진이 종료될 때까지 상주하기 때문에 언제든지 사용해도 된다.

ABCharacterSetting 언리얼 오브젝트의 클래스 기본 객체는 엔진 초기화 단계에서 생성자를 거쳐 INI에서 설정한 값이 할당되므로, ArenaBattleSetting 모듈 이후에 로딩되는 ArenaBattle 모듈에서 GetDefault 함수를 사용하면 INI에서 지정한 애셋의 목록 정보를 얻어올 수 있다.

구현부를 Private 폴더로 지정해놓는다면 구현부가 모여있는 Private 폴더에서만 모듈을 쓸 예정이기에 PrivateDependencyModuleNames에 추가한다.

// ArenaBattle.Build.cs

using UnrealBuildTool;

public class ArenaBattle : ModuleRules
{
	public ArenaBattle(ReadOnlyTargetRules Target) : base(Target)
	{
		PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;

		PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput", "UMG", "NavigationSystem", "AIModule", "GamePlayTasks" });

		PrivateDependencyModuleNames.AddRange(new string[] { "ArenaBattleSetting" });
	}
}

 

이제 GetDefault 함수를 사용해 애셋 목록을 읽어들인 후 하나씩 로그에 출력한다.

// ABCharacter.cpp

#include "ABCharacterSetting.h"

AABCharacter::AABCharacter()
{
	// ...
	if (DefaultSetting->CharacterAssets.Num() > 0)
	{
		for (auto CharacterAsset : DefaultSetting->CharacterAssets)
		{
			ABLOG(Warning, TEXT("Character Asset: %s"), *CharacterAsset.ToString());
		}
	}
}

 

생성자 로직에서 NPC가 생성될 때 랜덤하게 목록 중 하나를 골라 캐릭터 애셋을 로딩하도록 기능을 변경해본다.
언리얼 엔진은 게임 진행 중에도 비동기 방식으로 애셋을 로딩하도록 FStreamableManager라는 클래스를 제공한다.
이 매니저 클래스는 프로젝트에서 하나만 활성화하는 것이 좋기 때문에 유일한 인스턴스로 동작하는 ABGameInstance 클래스에서 이를 멤버 변수로 선언한다.

FStreamableManager에서 비동기 방식으로 애셋을 로딩하는 명령은 AsyncLoad다.
해당 함수에 FStreamableDelegate 형식의 델리게이트를 넘겨줄 경우, 애셋이 로딩을 완료하면 해당 델리게이트에 연결된 함수를 호출해준다. FStreamableDelegate 형식으로 델리게이트 멤버를 선언하고 넘겨줄 수 있지만 델리게이트에서 제공하는 CreateUObject 명령을 사용해 즉석에서 델리게이트를 생성함으로써 함수와 연동시킨 후 넘겨주는 방식이 간편하다.

// ABGameInstance.h

#pragma once

#include "ArenaBattle.h"
#include "Engine/DataTable.h"
#include "Engine/GameInstance.h"
#include "Engine/StreamableManager.h"
#include "ABGameInstance.generated.h"

/**
 * 
 */
USTRUCT(BlueprintType)
struct FABCharacterData : public FTableRowBase
{
	GENERATED_USTRUCT_BODY()

public:
	FABCharacterData() : Level(1), MaxHP(100.0f), Attack(10.0f), DropExp(10), NextExp(30) {}

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Data")
	int32 Level;
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Data")
	float MaxHP;
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Data")
	float Attack;
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Data")
	int32 DropExp;
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Data")
	int32 NextExp;
};

UCLASS()
class ARENABATTLE_API UABGameInstance : public UGameInstance
{
	GENERATED_BODY()
	
public:
	UABGameInstance();

	virtual void Init() override;
	FABCharacterData* GetABCharacterData(int32 Level);

	FStreamableManager StreamableManager;

private:
	UPROPERTY()
	class UDataTable* ABCharacterTable;
};
// ABCharacter.h

private:
	void OnAssetLoadCompleted();
	
	FSoftObjectPath CharacterAssetToLoad = FSoftObjectPath(nullptr);
	TSharedPtr<struct FStreamableHandle> AssetStreamingHandle;
// ABCharacter.cpp

#include "ABGameInstance.h"

void AABCharacter::BeginPlay()
{
	if (!IsPlayerControlled())
	{
		auto DefaultSetting = GetDefault<UABCharacterSetting>();
		int32 RandIndex = FMath::RandRange(0, DefaultSetting->CharacterAssets.Num() - 1);
		CharacterAssetToLoad = DefaultSetting->CharacterAssets[RandIndex];

		auto ABGameInstance = Cast<UABGameInstance>(GetGameInstance());
		if (nullptr != ABGameInstance)
		{
			AssetStreamingHandle = ABGameInstance->StreamableManager.RequestAsyncLoad(CharacterAssetToLoad, FStreamableDelegate::CreateUObject(this, &AABCharacter::OnAssetLoadCompleted));
		}
	}
}

void AABCharacter::OnAssetLoadCompleted()
{
	USkeletalMesh* AssetLoaded = Cast<USkeletalMesh>(AssetStreamingHandle->GetLoadedAsset());
	AssetStreamingHandle.Reset();
	if (nullptr != AssetLoaded)
	{
		GetMesh()->SetSkeletalMesh(AssetLoaded);
	}
}

 

 

게임 인스턴스는 게임 내 유일한 인스턴스를 가지는 싱글톤처럼 동작한다.
언리얼에서는 이와 별도로 싱글톤으로 동작하는 언리얼 오브젝트를 지정할 수 있다.

프로젝트 세팅 → 일반 세팅 → 게임 싱글톤 클래스에서 이를 지정할 수 있다.

 

레벨을 섹션이라는 단위로 나누고 하나의 섹션을 클리어하면 새로운 섹션이 등장하는 무한 맵 스테이지를 제작해본다.

섹션 액터가 해야 할 일은 다음과 같다.

  • 섹션의 배경과 네 방향으로 캐릭턴 입장을 통제하는 문을 제공한다.
  • 플레이어가 섹션에 진입하면 모든 문을 닫는다.
  • 문을 닫고 일정 시간 후에 섹션 중앙에서 NPC를 생성한다.
  • 문을 닫고 일정 시간 후에 아이템 상자를 섹션 내 랜덤한 위치에 생성한다.
  • 생성한 NPC가 죽으면 모든 문을 개방한다.
  • 통과한 문으로 이어지는 새로운 섹션을 생성한다.

새로운 섹션은 배경으로 SM_SQUARE 애셋을 사용하도록 한다.

// ABSection.h

#pragma once

#include "ArenaBattle/ArenaBattle.h"
#include "GameFramework/Actor.h"
#include "ABSection.generated.h"

UCLASS()
class ARENABATTLE_API AABSection : public AActor
{
	GENERATED_BODY()
	
public:	
	// Sets default values for this actor's properties
	AABSection();

protected:
	// Called when the game starts or when spawned
	virtual void BeginPlay() override;

private:
	UPROPERTY(VisibleAnywhere, Category = Mesh, meta = (AllowPrivateAccess = true))
	UStaticMeshComponent* Mesh;
};
// ABSection.cpp

#include "ABSection.h"

// Sets default values
AABSection::AABSection()
{
 	// Set this actor to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
	PrimaryActorTick.bCanEverTick = false;

	Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("MESH"));
	RootComponent = Mesh;

	FString AssetPath = TEXT("");
	static ConstructorHelpers::FObjectFinder<UStaticMesh> SM_SQUARE(*AssetPath);
	if (SM_SQUARE.Succeeded())
	{
		Mesh->SetStaticMesh(SM_SQUARE.Object);
	}
	else
	{
		ABLOG(Error, TEXT("Failed to load staticmesh asset. : %s"), *AssetPath);
	}
}

// Called when the game starts or when spawned
void AABSection::BeginPlay()
{
	Super::BeginPlay();
}

 

스태틱메시 애셋의 각 방향별로 출입문에 섹션을 이어붙일 수 있게 소켓이 부착되어 있다.

소켓은 소켓 매니저 창에서 관리할 수 있다.

 

 

사용할 철문 애셋을 열고 좌상단 표시 → 피벗 표시를 누르면 스태틱 메시 애셋의 피벗이 어디에 위치해있는지 확인할 수 있다.

 

 

소켓 목록을 제작하고 이를 사용해 철문을 각각 부착한다. 각각의 철문은 동일한 기능을 가지므로 TArray로 묶어 관리한다.

// ABSection.h

private:
	UPROPERTY(VisibleAnywhere, Category = Mesh, meta = (AllowPrivateAccess = true))
	TArray<UStaticMeshComponent*> GateMeshes;
// ABSection.cpp

#include "ABSection.h"

// Sets default values
AABSection::AABSection()
{
 	// Set this actor to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
	PrimaryActorTick.bCanEverTick = false;

	Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("MESH"));
	RootComponent = Mesh;

	FString AssetPath = TEXT("/Script/Engine.StaticMesh'/Game/Book/StaticMesh/SM_SQUARE.SM_SQUARE'");
	static ConstructorHelpers::FObjectFinder<UStaticMesh> SM_SQUARE(*AssetPath);
	if (SM_SQUARE.Succeeded())
	{
		Mesh->SetStaticMesh(SM_SQUARE.Object);
	}
	else
	{
		ABLOG(Error, TEXT("Failed to load staticmesh asset. : %s"), *AssetPath);
	}

	FString GateAssetPath = TEXT("");
	static ConstructorHelpers::FObjectFinder<UStaticMesh> SM_GATE(*GateAssetPath);
	if(!SM_GATE.Succeeded())
	{
		ABLOG(Error, TEXT("Failed to load staicmesh asset. : %s"), *GateAssetPath);
	}

	static FName GateSockets[] = { { TEXT("+XGate")}, { TEXT("-XGate")}, { TEXT("+YGate")}, { TEXT("-YGate")} };
	for (FName GateSocket : GateSockets)
	{
		ABCHECK(Mesh->DoesSocketExist(GateSocket));
		UStaticMeshComponent* NewGate = CreateDefaultSubobject<UStaticMeshComponent>(*GateSocket.ToString());
		NewGate->SetStaticMesh(SM_GATE.Object);
		NewGate->SetupAttachment(RootComponent, GateSocket);
		NewGate->SetRelativeLocation(FVector(0.0f, -80.5f, 0.0f));
		GateMeshes.Add(NewGate);
	}
}

 

코드를 적용하고 레벨에 ABSection 클래스를 배치하면 철문이 부착되어 있는 것을 확인할 수 있다.

 

이번에는 ABCharacter만을 감지하는 ABTrigger라는 이름의 특별한 콜리전 프리셋을 하나 추가한다.
이 콜리전 프리셋은 플레이어의 입장을 감지하고 섹션을 클리어한 후 출구를 선택할 때 사용한다.

 

해당 프리셋을 사용하는 Box 컴포넌트를 생성하고 섹션의 중앙과 각 철문 영역에 부착한다.

// ABSection.h

private:
	UPROPERTY(VisibleAnywhere, Category = Trigger, meta = (AllowPrivateAccess = true))
	TArray<UBoxComponent*> GateTriggers;

	UPROPERTY(VisibleAnywhere, Category = Mesh, meta = (AllowPrivateAccess = true))
	UBoxComponent* Trigger;
// ABSection.cpp

AABSection::AABSection()
{
	// ... 
	Trigger = CreateDefaultSubobject<UBoxComponent>(TEXT("TRIGGER"));
	Trigger->SetBoxExtent(FVector(775.0f, 775.0f, 300.0f));
	Trigger->SetupAttachment(RootComponent);
	Trigger->SetRelativeLocation(FVector(0.0f, 0.0f, 250.0f));
	Trigger->SetCollisionProfileName(TEXT("ABTrigger"));

	// ...
	static FName GateSockets[] = { { TEXT("+XGate")}, { TEXT("-XGate")}, { TEXT("+YGate")}, { TEXT("-YGate")} };
	for (FName GateSocket : GateSockets)
	{
		ABCHECK(Mesh->DoesSocketExist(GateSocket));
		UStaticMeshComponent* NewGate = CreateDefaultSubobject<UStaticMeshComponent>(*GateSocket.ToString());
		NewGate->SetStaticMesh(SM_GATE.Object);
		NewGate->SetupAttachment(RootComponent, GateSocket);
		NewGate->SetRelativeLocation(FVector(0.0f, -80.5f, 0.0f));
		GateMeshes.Add(NewGate);

		UBoxComponent* NewGateTrigger = CreateDefaultSubobject<UBoxComponent>(*GateSocket.ToString().Append(TEXT("Trigger")));
		NewGateTrigger->SetBoxExtent(FVector(100.0f, 100.0f, 300.0f));
		NewGateTrigger->SetupAttachment(RootComponent, GateSocket);
		NewGateTrigger->SetRelativeLocation(FVector(70.0f, 0.0f, 250.0f));
		NewGateTrigger->SetCollisionProfileName(TEXT("ABTrigger"));
		GateTriggers.Add(NewGateTrigger);
	}
}

 

 

액터 설정을 완료하고 액터의 로직을 스테이트 머신으로 원하는대로 설계한다. 책에서의 섹션 액터는 다음과 같은 스테이트를 가진다.

  • 준비 스테이트
  • 전투 스테이트
  • 완료 스테이트

원하는 대로의 스테이트 머신을 기획하고 열거형을 사용해 스테이트를 분류하고
각 스테이트별 기능을 ABSection 클래스에서 구현하도록 한다.

제작 단계에서 액터의 스테이트 상황을 볼 수 있다면 더욱 편하게 개발을 할 수 있을 것이다.
액터에는 에디터와 연동되는 OnConstruction이라는 특별한 함수가 설계돼 있다.
에디터 작업에서 선택한 액터의 속성이나 트랜스폼 정보가 변경될 때 이 OnConstuction 함수가 실행된다.

// ABSection.h

virtual void OnConstruction(const FTransform& Transform) override;

// ABSection.cpp

void AABSection::OnConstruction(const FTransform& Transform)
{
	Super::OnConstruction(Transform);
	SetState(bNoBattle ? ESectionState::COMPLETE : ESectionState::READY);
}