KoreanFoodie's Study

이득우 언리얼 C++ 15 : 게임의 완성 본문

Game Dev/Unreal C++ : Tutorial

이득우 언리얼 C++ 15 : 게임의 완성

GoldGiver 2022. 3. 27. 19:04

이득우님의 "이득우의 언리얼 C++ 게임 개발의 정석" 책을 따라가며 실습한 내용을 정리한 포스팅입니다. 실습에 필요한 자료들은 이 링크에서, 제가 작업한 예제 소스 완성본은 여기에서 찾아보실 수 있습니다. (저는 언리얼 4.27.2 버전 기준으로 작업하였습니다)

게임 데이터의 저장과 로딩

SaveGame 이라는 언리얼의 클래스를 상속받은 클래스를 이용해 게임 데이터를 저장하고 로드해보자. 이름은 ABSaveGame 으로 만든다.

 

게임 세이브 기능에는 각 저장 파일에 접근할 수 있는 고유 이름인 슬롯 이름이 필요하다. 슬롯 이름을 다르게 지정해 세이브 데이터를 여러 개 만들 수 있는데, 우리는 Player1 이라는 슬롯 이름을 사용한다. 기본 세이브 데이터 생성 로직은 플레이어 스테이트의 InitPlayerData 에 구현한다.

 

ABSaveGame.h

#include "ArenaBattle.h"
#include "GameFramework/SaveGame.h"
#include "ABSaveGame.generated.h"

UCLASS()
class ARENABATTLE_API UABSaveGame : public USaveGame
{
	GENERATED_BODY()
	
public:
	UABSaveGame();

	UPROPERTY()
	int32 Level;

	UPROPERTY()
	int32 Exp;

	UPROPERTY()
	FString PlayerName;

	UPROPERTY()
	int32 HighScore;
};

 

ABSaveGame.cpp

#include "ABSaveGame.h"

UABSaveGame::UABSaveGame()
{
	Level = 1;
	Exp = 0;
	PlayerName = TEXT("Guest");
	HighScore = 0;
}

 

ABPlayerState.h

...

UCLASS()
class ARENABATTLE_API AABPlayerState : public APlayerState
{

public:
    ...
    
    int32 GetGameHighScore() const;
    FString SaveSlotName;
    
    ...
    
    protected:
    
    ...
    
    UPROPERTY()
    int32 GameHighScore;
    
    ...
    
}

 

ABPlayerState.cpp

#include "ABSaveGmae.h"

...

AABPlayerState::AABPlayerState()
{
	...
    GameHighScore = 0;
	SaveSlotName = TEXT("Player1");
}

...

int32 AABPlayerState::GetGameHighScore()
{
	return GameHighScore;
}

...

void AABPlayerState::InitPlayerData()
{
	auto ABSaveGame = Cast<UABSaveGame>(UGameplayStatics::LoadGameFromSlot(SaveSlotName, 0));
	if (nullptr == ABSaveGame)
	{
		ABSaveGame = GetMutableDefault<UABSaveGame>();
	}

	SetPlayerName(ABSaveGame->PlayerName);
	SetCharacterLevel(ABSaveGame->Level);
	GameScore = 0;
	GameHighScore = ABSaveGame->HighScore;
	Exp = ABSaveGame->Exp;

	SavePlayerData();
}

void AABPlayerState::AddGameScore()
{
	GameScore++;
	if (GameScore >= GameHighScore)
	{
		GameHighScore = GameScore;
	}

	OnPlayerStateChanged.Broadcast();
	SavePlayerData();
}

이를 실행하면 UI 에 초기화된 플레이어의 데이터가 나타난다.

 

이제 플레이어에 관련된 데이터 (경험치 등) 이 변동될 때마다 이를 저장하도록 기능을 구현한다.

 

ABPlayerState.h

UCLASS()
class ARENABATTLE_API AABPlayerState : public APlayerState
{
	...

public:
	void SavePlayerData();
    
	...
}

 

ABPlayerState.cpp

bool AABPlayerState::AddExp(int32 IncomeExp)
{
	...
    
	SavePlayerData();

	return DidLevelUp;
}

void AABPlayerState::AddGameScore()
{
	...
    
	SavePlayerData();
}

void AABPlayerState::InitPlayerData()
{
	...

	SavePlayerData();
}

...

void AABPlayerState::SavePlayerData()
{
	UABSaveGame* NewPlayerData = NewObject<UABSaveGame>();
	NewPlayerData->PlayerName = GetPlayerName();
	NewPlayerData->Level = CharacterLevel;
	NewPlayerData->Exp = Exp;
	NewPlayerData->HighScore= GameHighScore;

	if (!UGameplayStatics::SaveGameToSlot(NewPlayerData, SaveSlotName, 0))
	{
		ABLOG(Error, TEXT("SaveGame Error!"));
	}
}

언리얼 오브젝트를 생성할 때는 NewObject 명령을 사용하며, NewObject 로 생성된 오브젝트를 더 이상 사용하지 않으면 언리얼 실행 환경의 가비지 컬렉터 (Garbage Collector) 가 이를 탐지해 자동으로 언리얼 오브젝트를 소멸시킨다. (delete 키워드 필요없음)

월드에 액터를 생성하는 작업도 언리얼 오브젝트를 생성하는 작업이다. 다만 액터 생성시 고려사항이 많으므로 이를 포괄한 SpawnActor 라는 API 를 활용하는 것이다. SpawnActor 의 내부에서 결국 NewObject 를 사용해 액터를 생성한다.

 

이제 플레이어스테이트의 하이스코어 값을 HUD UI 에 연동시킨다.

 

ABHUDWidget.cpp

...

void UABHUDWidget::UpdatePlayerState()
{
	...
	HighScore->SetText(FText::FromString(FString::FromInt(CurrentPlayerState->GetGameHighScore())));
}

 

 

하이스코어가 잘 저장된다.

 

저장된 데이터를 없애고 다시 시작하고 싶은 경우 프로젝트의 Saved 폴더 내의 SaveGames 폴더에서 해당 파일을 삭제하면 된다(파일명이 슬롯 이름과 동일).

 

 

전투 시스템의 설계

이번에는 게임 진행의 난이도를 점진적으로 높이고 재미를 위해 전투에 관련된 부가 요소를 추가해보자.

  1. 플레이어는 게임 진행 중에 HP를 회복할 수 없고 오직 레벨업을 할 때만 회복된다.
  2. 캐릭터는 무기를 들 때 더 긴 공격 범위를 가진다.
  3. 무기에는 공격력 증가치가 랜덤으로 부여되며, 운이 없으면 오히려 무기에 의해 공격력이 저하될 수 있다.
  4. 현재 게임 스코어가 높을수록 생성되는 NPC의 레벨도 증가한다.

1번은 이미 구현했으므로 나머지 기능들을 구현해 보자.

 

무기 액터인 ABWeapon 에 AttackRange 라는 속성을 추가한다. 캐릭터에 무기가 없으면 캐릭터의 AttackRange 속성을 사용하고 캐릭터가 무기를 들면 무기의 AttackRange 속성을 사용하도록 로직을 수정한다.

해당 속성은 EditAnywhere, BlueprintReadWrite 를 지정해 앞으로 ABWeapon 클래스를 상속받은 무기 블루프린트에서도 공격 범위 값을 다르게 설정할 수 있도록 기능을 부여한다. 그리고 캐릭터가 무기를 들고 있더라도 무기를 변경할 수 있도록 CanSetWeapon 값을 무조건 true 로 설정한다.

 

ABWeapon.h

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

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

	float GetAttackRange() const;
	float GetAttackDamage() const;
	float GetAttackModifier() const;

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

public:	
	UPROPERTY(VisibleAnywhere, Category = Weapon)
	USkeletalMeshComponent* Weapon;

protected:
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Attack)
	float AttackRange;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Attack)
	float AttackDamageMin;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Attack)
	float AttackDamageMax;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Attack)
	float AttackModifierMin;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Attack)
	float AttackModifierMax;

	UPROPERTY(Transient, VisibleInstanceOnly, BlueprintReadOnly, Category = Attack)
	float AttackDamage;

	UPROPERTY(Transient, VisibleInstanceOnly, BlueprintReadOnly, Category = Attack)
	float AttackModifier;
};

Modifier 값을 이용해 Damage 와 Range 를 일정 범위 내에서 조정할 것이다.

 

ABWeapon.cpp

#include "ABWeapon.h"

// Sets default values
AABWeapon::AABWeapon()
{
 	// 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;

	Weapon = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("WEAPON"));
	RootComponent = Weapon;

	static ConstructorHelpers::FObjectFinder<USkeletalMesh> SK_WEAPON(TEXT("/Game/InfinityBladeWeapons/Weapons/Blade/Swords/Blade_BlackKnight/SK_Blade_BlackKnight.SK_Blade_BlackKnight"));
	if (SK_WEAPON.Succeeded())
	{
		Weapon->SetSkeletalMesh(SK_WEAPON.Object);
	}
	Weapon->SetCollisionProfileName(TEXT("NoCollision"));

	AttackRange = 150.0f;
	AttackDamageMin = -2.5f;
	AttackDamageMax = 10.0f;
	AttackModifierMin = 0.85f;
	AttackModifierMax = 1.25f;
}

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

	AttackDamage = FMath::RandRange(AttackDamageMin, AttackDamageMax);
	AttackModifier = FMath::RandRange(AttackModifierMin, AttackModifierMax);
	ABLOG(Warning, TEXT("Weapon Damage : %f, Modifier : %f"), AttackDamage, AttackModifier);
}

float AABWeapon::GetAttackRange() const
{
	return AttackRange;
}

float AABWeapon::GetAttackDamage() const
{
	return AttackDamage;
}

float AABWeapon::GetAttackModifier() const
{
	return AttackModifier;
}

 

ABCharacter.h

...

UCLASS()
class ARENABATTLE_API AABCharacter : public ACharacter
{

	...
    
public:
    
	float GetFinalAttackRange() const;
	float GetFinalAttackDamage() const;
}

 

ABCharacter.cpp

...

float AABCharacter::GetFinalAttackRange() const
{
	return (nullptr != CurrentWeapon) ? CurrentWeapon->GetAttackRange() : AttackRange;
}

float AABCharacter::GetFinalAttackDamage() const
{
	float AttackDamage = (nullptr != CurrentWeapon) ? (CharacterStat->GetAttack() + CurrentWeapon->GetAttackDamage()) : CharacterStat->GetAttack();
	float AttackModifier = (nullptr != CurrentWeapon) ? (CurrentWeapon->GetAttackModifier()) : 1.0f;;

	return AttackDamage * AttackModifier;
}

...

bool AABCharacter::CanSetWeapon()
{
	return true;
}

void AABCharacter::SetWeapon(class AABWeapon* NewWeapon)
{
	ABCHECK(nullptr != NewWeapon);

	if (nullptr != CurrentWeapon)
	{
		ABLOG(Warning, TEXT("CurrentWeapon should be replaced"));
		CurrentWeapon->DetachFromActor(FDetachmentTransformRules::KeepWorldTransform);
		CurrentWeapon->Destroy();
		CurrentWeapon = nullptr;
	}

	...
}

void AABCharacter::AttackCheck()
{
	float FinalAttackRange = GetFinalAttackRange();
	 
	FHitResult HitResult;
	FCollisionQueryParams Params(NAME_None, false, this);
	bool bResult = GetWorld()->SweepSingleByChannel(
		HitResult, GetActorLocation(), GetActorLocation() + GetActorForwardVector() * FinalAttackRange,
		FQuat::Identity, ECollisionChannel::ECC_GameTraceChannel2, FCollisionShape::MakeSphere(50.0f), Params);

#if ENABLE_DRAW_DEBUG
	FVector TraceVec = GetActorForwardVector() * FinalAttackRange;
	FVector Center = GetActorLocation() + TraceVec * 0.5f;
	float HalfHeight = FinalAttackRange * 0.5f + AttackRadius;
	FQuat CapsuleRot = FRotationMatrix::MakeFromZ(TraceVec).ToQuat();
	FColor DrawColor = bResult ? FColor::Green : FColor::Red;
	float DebugLifeTime = 5.0f;

	DrawDebugCapsule(GetWorld(), Center, HalfHeight, AttackRadius, CapsuleRot, DrawColor, false, DebugLifeTime);
#endif
		
	if (bResult)
	{
		if (HitResult.Actor.IsValid())
		{
			ABLOG(Warning, TEXT("Hit Actor Name : %s"), *HitResult.Actor->GetName());
			FDamageEvent DamageEvent;
			HitResult.Actor->TakeDamage(GetFinalAttackDamage(), DamageEvent, GetController(), this);
		}
	}
}

AttackCheck 은 복잡해 보이지만 사실 AttackRange 를 FinalAttackRange 로 바꾼 것 뿐이다.

BTDecorator_IsInAttackRange.cpp 도 마찬가지로 수정해준다.

 

BTDecorator_IsInAttackRange.cpp

...

bool UBTDecorator_IsInAttackRange::CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const
{
	bool bResult = Super::CalculateRawConditionValue(OwnerComp, NodeMemory);

	//auto ControllingPawn = OwnerComp.GetAIOwner()->GetPawn();
	auto ControllingPawn = Cast<AABCharacter>(OwnerComp.GetAIOwner()->GetPawn());
	if (nullptr == ControllingPawn)
		return false;

	auto Target = Cast<AABCharacter>(OwnerComp.GetBlackboardComponent()->GetValueAsObject(AABAIController::TargetKey));
	if (nullptr == Target)
		return false;

	bResult = (Target->GetDistanceTo(ControllingPawn) <= ControllingPawn->GetFinalAttackRange());
	return bResult;
}

 

이제 무기를 들면 범위가 늘어나고, 공격력이 강해지는 것을 확인할 수 있다.

 

이제 NPC 의 레벨을 조정해보자. NPC 캐릭터의 LOADING 스테이트에서 현재 게임 스코어를 게임 모드로부터 가져온다.

 

ABGameMode.h

...

UCLASS()
class ARENABATTLE_API AABGameMode : public AGameModeBase
{

	...
    
public:

	int32 GetScore() const;
};

 

ABGameMode.cpp

...

int32 AABGameMode::GetScore() const
{
	return ABGameState->GetTotalGameScore();
}

 

ABCharacter.cpp

...

#include "ABGameMode.h"

...

void AABCharacter::SetCharacterState(ECharacterState NewState)
{
	ABCHECK(CurrentState != NewState);
	CurrentState = NewState;

	switch (CurrentState)
	{
		case ECharacterState::LOADING:
		{
			if (bIsPlayer)
			{
				DisableInput(ABPlayerController);

				ABPlayerController->GetHUDWidget()->BindCharacterStat(CharacterStat);

				auto ABPlayerState = Cast<AABPlayerState>(GetPlayerState());
				ABCHECK(nullptr != ABPlayerState);
				CharacterStat->SetNewLevel(ABPlayerState->GetCharacterLevel());
			}
			else
			{
				auto ABGameMode = Cast<AABGameMode>(GetWorld()->GetAuthGameMode());
				ABCHECK(nullptr != ABGameMode);
				int32 TargetLevel = FMath::CeilToInt(((float)ABGameMode->GetScore() * 0.8f));
				int32 FinalLevel = FMath::Clamp<int32>(TargetLevel, 1, 20);
				ABLOG(Warning, TEXT("New NPC Level : %d"), FinalLevel);
				CharacterStat->SetNewLevel(FinalLevel);
			}

		...
	}

}

...

캐릭터가 플레이어가 아닐 경우 Score * 0.8 의 수치로 레벨을 설정했다.

 

 

타이틀 화면 제작

게임 타이틀 화면을 추가해 보자. 예제 코드 파일의 Resource > Chapter15 > Book >UI 에서 UI_Title.uasset 파일을 Content > Book > UI 폴더에 복사한다.

빈 레벨 템플릿을 만들고, Title 이라는 이름으로 저장한다.

해당 Title 맵을 '맵 & 모드' 설정에서 기본 맵으로 설정한다. 

해당 레벨은 UI 화면만 띄우는 역할을 수행할 것이므로, 해당 레벨에서 사용할 게임 모드와 UI 를 띄울 플레이어 컨트롤러를 제작한다. 먼저 플레이어 컨트롤러를 만든다. ABUIPlayerController 라는 이름의 클래스를 PlayerController 클래스를 부모로 하여 생성한다.

새로운 플레이어 컨트롤러 클래스를 생성하면, 이를 상속받은 블루프린트에서 앞으로 띄울 UI의 클래스 값을 에디터에서 설정할 수 있도록 위젯 클래스 속성을 추가하고 EditDefaultOnly 키워드를 지정한다.

플레이어 컨트롤러 로직은 게임을 시작하면 해당 클래스로부터 UI 인스턴스를 생성하고, 이를 뷰포트에 띄운 후에 입력은 UI 에만 전달되도록 제작한다.

 

ABUIPlayerController.h

#include "ArenaBattle.h"
#include "GameFramework/PlayerController.h"
#include "ABUIPlayerController.generated.h"

/**
 * 
 */
UCLASS()
class ARENABATTLE_API AABUIPlayerController : public APlayerController
{
	GENERATED_BODY()
	
protected:
	virtual void BeginPlay() override;

	UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = UI, Meta=(bIsFocusable = true))
	TSubclassOf<class UUserWidget> UIWidgetClass;

	UPROPERTY()
	class UUserWidget* UIWidgetInstance;
};

 

ABUIPlayerController.cpp

#include "ABUIPlayerController.h"
#include "Blueprint/UserWidget.h"

void AABUIPlayerController::BeginPlay()
{
	Super::BeginPlay();

	ABCHECK(nullptr != UIWidgetClass);

	UIWidgetInstance = CreateWidget<UUserWidget>(this, UIWidgetClass);

	ABCHECK(nullptr != UIWidgetInstance);

	UIWidgetInstance->AddToViewport();

	FInputModeUIOnly Mode;
	Mode.SetWidgetToFocus(UIWidgetInstance->GetCachedWidget());
	SetInputMode(Mode);
	bShowMouseCursor = true;
}

 

ABUIPlayerController 를 생성했으면, 이를 기반으로 한 블루프린트 클래스를 생성한다. 이름은 BP_TitleUIPlayerController 로 한다. 그리고 아래와 같이 설정을 바꿔준다.

 

플레이어 컨트롤러의 세팅이 끝나면 이 플레이어 컨트롤러를 띄울 게임 모들르 생성한다. Game Mode Base 를 기반으로 한 블루프린트 클래스를 생성한다. 이름은 BP_TitleGameMode 로 한다. 게임 모드를 열어 Default Pawn Class 를 아무 기능이 없는 Pawn 으로, Player Controller Class 의 정보를 방금 생성한 BP_TitleUIPlayerController 로 설정한다.

 

게임 모드를 BP_TitleGameMode 로 변경한 후 실행하면, 타이틀 UI 가 뜨게 된다.

타이틀 UI 의 블루프린트 로직을 보면, 새로 시작하기 버튼을 누르면 Select 레벨로, 이어하기 버튼을 누르면 Gameplay 레벨로 이동하는 것을 알 수 있다. 이제 Select 레벨을 만들어 보자.

Title 을 만들었던 것과 동일하게, BP_SelectUIPlayerController 와 BP_SelectGameMode 를 만들고 설정값을 맞춰서 바꾸어 준다. 그 후 C++ 로 각 버튼의 로직을 구현한다.

먼저 UserWidget 을 기반으로 하는 ABCharacterSelectWidget 클래스를 생성한다. 이 클래스는 연재 레벨의 스켈레탈 메시 목록을 가져오고 버튼을 누를 때마다 스켈레탈 메시 컴포넌트에 지정한 스켈레탈 메시를 변경하는 기능을 구현해준다. 현재 월드에 있는 특정 타입을 상속받은 액터의 목록은 TActorIterator<액터 타입> 구문을 사용해 가져올 수 있다.

우리는 다음과 같은 화면을 구현할 것이다.

 

ABCharacterSelectWidget.h

#include "ArenaBattle.h"
#include "Blueprint/UserWidget.h"
#include "ABCharacterSelectWidget.generated.h"

/**
 * 
 */
UCLASS()
class ARENABATTLE_API UABCharacterSelectWidget : public UUserWidget
{
	GENERATED_BODY()
	
protected:
	UFUNCTION(BlueprintCallable)
	void NextCharacter(bool bForward = true);

	virtual void NativeConstruct() override;

protected:
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Character)
	int32 CurrentIndex;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Character)
	int32 MaxIndex;

	TWeakObjectPtr<USkeletalMeshComponent> TargetComponent;

	UPROPERTY()
	class UButton* PrevButton;

	UPROPERTY()
	class UButton* NextButton;

	UPROPERTY()
	class UEditableTextBox* TextBox;

	UPROPERTY()
	class UButton* ConfirmButton;
	
private:
	UFUNCTION()
	void OnPrevClicked();

	UFUNCTION()
	void OnNextClicked();

	UFUNCTION()
	void OnConfirmClicked();
};

PrevButton, NextButton 은 캐릭터를 바꾸는 버튼, TextBox 는 캐릭터 이름 입력 창, ConfirmButton 은 캐릭터 생성 버튼이다. 

NextCharacter 함수는 블루프린트에서도 사용할 수 있도록 BlueprintCallable 키워드를 지정했다.

 

ABCharacterCharacterSelectWidget.cpp

// Fill out your copyright notice in the Description page of Project Settings.


#include "ABCharacterSelectWidget.h"
#include "ABCharacterSetting.h"
#include "ABGameInstance.h"
#include "EngineUtils.h"
#include "Animation/SkeletalMeshActor.h"
#include "Components/Button.h"
#include "Components/EditableTextBox.h"
#include "ABSaveGame.h"
#include "ABPlayerState.h"

void UABCharacterSelectWidget::NextCharacter(bool bForward)
{
	bForward ? ++CurrentIndex : --CurrentIndex;

	if (CurrentIndex == -1) CurrentIndex = MaxIndex - 1;
	if (CurrentIndex == MaxIndex) CurrentIndex = 0;

	auto CharacterSetting = GetDefault<UABCharacterSetting>();
	auto AssetRef = CharacterSetting->CharacterAssets[CurrentIndex];

	auto ABGameInstance = GetWorld()->GetGameInstance<UABGameInstance>();
	ABCHECK(nullptr != ABGameInstance);
	ABCHECK(TargetComponent.IsValid());

	USkeletalMesh* Asset = ABGameInstance->StreamableManager.LoadSynchronous<USkeletalMesh>(AssetRef);
	if (nullptr != Asset)
	{
		TargetComponent->SetSkeletalMesh(Asset);
	}
}

void UABCharacterSelectWidget::NativeConstruct()
{
	Super::NativeConstruct();
	
	CurrentIndex = 0;
	auto CharacterSetting = GetDefault<UABCharacterSetting>();
	MaxIndex = CharacterSetting->CharacterAssets.Num();

	for (TActorIterator<ASkeletalMeshActor> It(GetWorld()); It; ++It)
	{
		TargetComponent = It->GetSkeletalMeshComponent();
		break;
	}

	PrevButton = Cast<UButton>(GetWidgetFromName(TEXT("btnPrev")));
	ABCHECK(nullptr != PrevButton);

	NextButton = Cast<UButton>(GetWidgetFromName(TEXT("btnNext")));
	ABCHECK(nullptr != NextButton);

	TextBox = Cast<UEditableTextBox>(GetWidgetFromName(TEXT("edtPlayerName")));
	ABCHECK(nullptr != TextBox);

	ConfirmButton = Cast<UButton>(GetWidgetFromName(TEXT("btnConfirm")));
	ABCHECK(nullptr != ConfirmButton);

	PrevButton->OnClicked.AddDynamic(this, &UABCharacterSelectWidget::OnPrevClicked);
	NextButton->OnClicked.AddDynamic(this, &UABCharacterSelectWidget::OnNextClicked);
	ConfirmButton->OnClicked.AddDynamic(this, &UABCharacterSelectWidget::OnConfirmClicked);
}

void UABCharacterSelectWidget::OnPrevClicked()
{
	NextCharacter(false);
}

void UABCharacterSelectWidget::OnNextClicked()
{
	NextCharacter(true);
}

void UABCharacterSelectWidget::OnConfirmClicked()
{
	FString CharacterName = TextBox->GetText().ToString();
	if (CharacterName.Len() <= 0 || CharacterName.Len() > 10) return;

	UABSaveGame* NewPlayerData = NewObject<UABSaveGame>();
	NewPlayerData->PlayerName = CharacterName;
	NewPlayerData->Level = 1;
	NewPlayerData->Exp = 0;
	NewPlayerData->HighScore = 0;
	NewPlayerData->CharacterIndex = CurrentIndex;

	auto ABPlayerState = GetDefault<AABPlayerState>();
	if (UGameplayStatics::SaveGameToSlot(NewPlayerData, ABPlayerState->SaveSlotName, 0))
	{
		ABLOG(Warning, TEXT("OpenLevel : Gameplay"));
		UGameplayStatics::OpenLevel(GetWorld(), TEXT("Gameplay"));
	}
	else
	{
		ABLOG(Error, TEXT("SaveGame Error!"));
	}

}

추후 살펴볼 것이지만, OnConfirmClicked 함수는 세이브 데이터를 불러와서 PlayerState 를 갱신해 준 다음, Gameplay 레벨을 열어주도록 만들었다. UI_Select 에셋의 부모 클래스도 ABCharacterSelectWidget 으로 변경해야 한다.

현재 선택한 캐릭터가 게임 플레이에서도 동일하게 나오도록 하기 위해, Gameplay 레벨에서 이를 저장하고 로딩하는 기능을 만들어야 한다.

 

ABSaveGame.h

#include "ArenaBattle.h"
#include "GameFramework/SaveGame.h"
#include "ABSaveGame.generated.h"

/**
 * 
 */
UCLASS()
class ARENABATTLE_API UABSaveGame : public USaveGame
{
	GENERATED_BODY()
	
public:
	UABSaveGame();

	UPROPERTY()
	int32 Level;

	UPROPERTY()
	int32 Exp;

	UPROPERTY()
	FString PlayerName;

	UPROPERTY()
	int32 HighScore;

	UPROPERTY()
	int32 CharacterIndex;
};

 

ABSaveGame.cpp

#include "ABSaveGame.h"

UABSaveGame::UABSaveGame()
{
	Level = 1;
	Exp = 0;
	PlayerName = TEXT("Guest");
	HighScore = 0;
	CharacterIndex = 0;
}

 

ABPlayerState.h

...

UCLASS()
class ARENABATTLE_API AABPlayerState : public APlayerState
{
	GENERATED_BODY()
	
public:

	...

	int32 GetCharacterIndex() const;

protected:

	...

	UPROPERTY(Transient)
	int32 CharacterIndex;

	...
};

 

ABPlayerState.cpp

...

AABPlayerState::AABPlayerState()
{
	...
    
	CharacterIndex = 0;
}

...

void AABPlayerState::InitPlayerData()
{
	...
    
	CharacterIndex = ABSaveGame->CharacterIndex;

	SavePlayerData();
}

void AABPlayerState::SavePlayerData()
{
	...
	
	NewPlayerData->CharacterIndex = CharacterIndex;

	...
}

...

 

ABCharacter.cpp

...

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

	...

	auto DefaultSetting = GetDefault<UABCharacterSetting>();

	if (bIsPlayer)
	{
		auto ABPlayerState = Cast<AABPlayerState>(GetPlayerState());
		ABCHECK(nullptr != ABPlayerState);
		AssetIndex = ABPlayerState->GetCharacterIndex();
	}
	else
	{
		AssetIndex = FMath::RandRange(0, DefaultSetting->CharacterAssets.Num() - 1);
	}

	...

}

...

이제 Title 을 기본 레벨로 설정한다.

 

 

게임의 중지와 결과 화면

게임 결과를 띄우는 화면을 만들어 보자. UI_Title 을 가져왔던 폴더에서 UI_Result.uasset 과 UI_Menu.asset 파일을 복사한다.

위젯에서 사용하는 버튼은 다음과 같다.

  • btnResume : 현재 진행 중인 게임으로 돌아감
  • btnReturnToTitle : 타이틀 레벨로 돌아감
  • btnRetryGame : 게임에 재도전함

액션 매핑에 GamePause 라는 이름으로 M 키를 바인딩한다.

이제 플레이어 컨트롤러에서 해당 키를 인식시키는 코드를 추가한다.

 

ABPlayerController.h

...

UCLASS()
class ARENABATTLE_API AABPlayerController : public APlayerController
{
    ...

protected:
    virtual void SetupInputComponent() override;

	...
    
private:
	void OnGamePause();

	...
}

 

ABPlayerController.cpp

...

void AABPlayerController::SetupInputComponent()
{
	Super::SetupInputComponent();
	InputComponent->BindAction(TEXT("GamePause"), EInputEvent::IE_Pressed, this, &AABPlayerController::OnGamePause);
}

void AABPlayerController::OnGamePause()
{
	MenuWidget = CreateWidget<UABGameplayWidget>(this, MenuWidgetClass);
	ABCHECK(nullptr != MenuWidget);
	MenuWidget->AddToViewport(3);

	SetPause(true);
	ChangeInputMode(false);
}

...

이제 UI 가 공용으로 사용할 기본 C++ 클래스를 ABGameplayWidget 이라는 이름으로 생성한다. 이전과 동일하게 UserWidget 을 부모로 하여 생성한다.

새로 생성한 C++ 클래스를 UI_Menu 애셋의 부모 클래스로 지정해야 한다.

이제 UI 위젯을 초기화하는 시점에서 발생하는 NativeConstruct 함수에서 이름으로 버튼을 찾고, 해당 이름의 버튼이 존재하면 바인딩하도록 로직을 구현한다.

 

ABGameplayWidget.h

#include "ArenaBattle.h"
#include "Blueprint/UserWidget.h"
#include "ABGameplayWidget.generated.h"

/**
 * 
 */
UCLASS()
class ARENABATTLE_API UABGameplayWidget : public UUserWidget
{
	GENERATED_BODY()
	
protected:
	virtual void NativeConstruct() override;

	UFUNCTION()
	void OnResumeClicked();

	UFUNCTION()
	void OnReturnToTitleClicked();

	UFUNCTION()
	void OnRetryGameClicked();

protected:
	UPROPERTY()
	class UButton* ResumeButton;

	UPROPERTY()
	class UButton* ReturnToTitleButton;

	UPROPERTY()
	class UButton* RetryGameButton;
};

 

ABGameplayWidget.cpp

#include "ABGameplayWidget.h"
#include "Components/Button.h"
#include "ABPlayerController.h"

void UABGameplayWidget::NativeConstruct()
{
	Super::NativeConstruct();

	ResumeButton = Cast<UButton>(GetWidgetFromName(TEXT("btnResume")));
	if (nullptr != ResumeButton)
	{
		ResumeButton->OnClicked.AddDynamic(this, &UABGameplayWidget::OnResumeClicked);
	}

	ReturnToTitleButton = Cast<UButton>(GetWidgetFromName(TEXT("btnReturnToTitle")));
	if (nullptr != ReturnToTitleButton)
	{
		ReturnToTitleButton->OnClicked.AddDynamic(this, &UABGameplayWidget::OnReturnToTitleClicked);
	}

	RetryGameButton = Cast<UButton>(GetWidgetFromName(TEXT("btnRetryGame")));
	if (nullptr != RetryGameButton)
	{
		RetryGameButton->OnClicked.AddDynamic(this, &UABGameplayWidget::OnRetryGameClicked);
	}
}

void UABGameplayWidget::OnResumeClicked()
{
	auto ABPlayerController = Cast<AABPlayerController>(GetOwningPlayer());
	ABCHECK(nullptr != ABPlayerController);

	RemoveFromParent();
	ABPlayerController->ChangeInputMode(true);
	ABPlayerController->SetPause(false);
}

void UABGameplayWidget::OnReturnToTitleClicked()
{
	UGameplayStatics::OpenLevel(GetWorld(), TEXT("Title"));
}

void UABGameplayWidget::OnRetryGameClicked()
{
	auto ABPlayerController = Cast<AABPlayerController>(GetOwningPlayer());
	ABCHECK(nullptr != ABPlayerController);
	ABPlayerController->RestartLevel();
}

 

이제 M 버튼을 눌렀을 때 메뉴 UI 가 나타나도록 기능을 추가한다. 메뉴 UI 가 나오면 고려할 사항들은 다음과 같다.

  1. 게임 플레이의 중지
  2. 버튼을 클릭할 수 있도록 마우스 커서 보여주기
  3. 입력이 게임에 전달되지 않고 UI 에만 전달되도록 제어

플레이어 컨트롤러의 SetPause 함수를 사용해 플레이를 일시중지한다. 마우스 커서는 플레이어 컨트롤러의 bShowMouseCursor 속성을 true 로 설정하면 보이며, UI 에만 입력을 전달하도록 SetInputMode 함수에 FInputModeUIOnly 클래스를 인자로 넣어준다.

 

ABPlayerController.h

...

UCLASS()
class ARENABATTLE_API AABPlayerController : public APlayerController
{
	GENERATED_BODY()	

	...

	void ShowResultUI();

protected:

	...

	UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = UI)
	TSubclassOf<class UABGameplayWidget> MenuWidgetClass;

private:

	...

	UPROPERTY()
	class UABGameplayWidget* MenuWidget;

	FInputModeGameOnly GameInputMode;
	FInputModeUIOnly UIInputMode;
}

 

ABPlayerController.cpp

...

#include "ABGameplayWidget.h"

AABPlayerController::AABPlayerController()
{
	...

	static ConstructorHelpers::FClassFinder<UABGameplayWidget> UI_MENU_C(TEXT("/Game/Book/UI/UI_Menu.UI_Menu_C"));
	if (UI_MENU_C.Succeeded())
	{
		MenuWidgetClass = UI_MENU_C.Class;
	}
}

void AABPlayerController::BeginPlay()
{
	Super::BeginPlay();

	ChangeInputMode(true);

	HUDWidget = CreateWidget<UABHUDWidget>(this, HUDWidgetClass);
	ABCHECK(nullptr != HUDWidget);
	HUDWidget->AddToViewport(1);

	...
}

...

void AABPlayerController::ChangeInputMode(bool bGameMode)
{
	if (bGameMode)
	{
		SetInputMode(GameInputMode);
		bShowMouseCursor = false;
	}
	else
	{
		SetInputMode(UIInputMode);
		bShowMouseCursor = true;
	}
}

...

void AABPlayerController::OnGamePause()
{
	MenuWidget = CreateWidget<UABGameplayWidget>(this, MenuWidgetClass);
	ABCHECK(nullptr != MenuWidget);
	MenuWidget->AddToViewport(3);

	SetPause(true);
	ChangeInputMode(false);
}

 

M 버튼을 누른 화면은 다음과 같다.

이미 ABGameplayWidget.cpp 에 구현한 내용인데, UI 시스템은 RemoveFromParent 함수를 사용해 현재 뷰포트에 띄워진 자신을 제거할 수 있다. UI 는 GetOwningPlayer 함수를 사용해 현재 자신을 생성하고 관리하는 플레이어 컨트롤러의 정보를 가져올 수 있다. 이를 이용해 입력과 게임의 진행을 원래대로 되돌려놓는다.

 

이제 마지막으로 결과 화면 UI 를 표시하자.

앞서 제작한 ABGameplayWidget 을 상속받은 ABGameplayResultWidget 이라는 새로운 클래스를 생성한다. 앞서 이야기한 버튼 3 개의 기능을 설계한다.

 

ABGameplayResultWidget.h

#include "ArenaBattle.h"
#include "ABGameplayWidget.h"
#include "ABGameplayResultWidget.generated.h"

/**
 * 
 */
UCLASS()
class ARENABATTLE_API UABGameplayResultWidget : public UABGameplayWidget
{
	GENERATED_BODY()

public:
	void BindGameState(class AABGameState* GameState);
	
protected:
	virtual void NativeConstruct() override;

private:
	TWeakObjectPtr<class AABGameState> CurrentGameState;
};

 

ABGameplayResultWidget.cpp

#include "ABGameplayResultWidget.h"
#include "Components/TextBlock.h"
#include "ABGameState.h"

void UABGameplayResultWidget::BindGameState(AABGameState* GameState)
{
	ABCHECK(nullptr != GameState);
	CurrentGameState = GameState;
}

void UABGameplayResultWidget::NativeConstruct()
{
	Super::NativeConstruct();

	ABCHECK(CurrentGameState.IsValid());

	auto Result = Cast<UTextBlock>(GetWidgetFromName(TEXT("txtResult")));
	ABCHECK(nullptr != Result);
	Result->SetText(FText::FromString(CurrentGameState->IsGameCleared() ? TEXT("Mission Complete") : TEXT("Mission Failed")));

	auto TotalScore = Cast<UTextBlock>(GetWidgetFromName(TEXT("txtTotalScore")));
	ABCHECK(nullptr != TotalScore);
	TotalScore->SetText(FText::FromString(FString::FromInt(CurrentGameState->GetTotalGameScore())));
}

컴파일 후, UI_Result 애셋의 부모 클래스로 ABGameplayResultWidget 을 설정한다.

결과 UI 는 게임이 종료되면 마지막으로 한 번만 띄워진다. UI 의 인스턴스는 시작할 때 미리 만들어 줬다가 게임이 종료될 때 만들어진 인스턴스를 뷰포트에 띄우도록 로직을 구성했다.

 

ABPlayerController.h

...

UCLASS()
class ARENABATTLE_API AABPlayerController : public APlayerController
{
	GENERATED_BODY()	
public:

	...

	void ShowResultUI();

protected:

	...

	UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = UI)
	TSubclassOf<class UABGameplayResultWidget> ResultWidgetClass;

private:

	...

	UPROPERTY()
	class UABGameplayResultWidget* ResultWidget;
};

 

ABPlayerController.cpp

#include "ABGameplayResultWidget.h"

AABPlayerController::AABPlayerController()
{

	...

	static ConstructorHelpers::FClassFinder<UABGameplayResultWidget> UI_RESULT_C(TEXT("/Game/Book/UI/UI_Result.UI_Result_C"));
	if (UI_RESULT_C.Succeeded())
	{
		ResultWidgetClass = UI_RESULT_C.Class;
	}
}

void AABPlayerController::BeginPlay()
{

	...

	ResultWidget = CreateWidget<UABGameplayResultWidget>(this, ResultWidgetClass);
	ABCHECK(nullptr != ResultWidget);
}

...

void AABPlayerController::ShowResultUI()
{
	ResultWidget->AddToViewport();
	ChangeInputMode(false);
}

UI 를 구현하고 나면, 이제 게임 플레이가 종료되는 시점을 지정하자.

플레이어가 죽을 때와 목표를 달성 했을 때 두 가지가 있다. 우선 두 개의 섹션을 COMPLETE 스테이트로 만들면 달성하는 것으로 정한다.

게임의 미션을 달성했는지의 여부는 GameState 에 bGameCleared 라는 속성을 추가해 체크한다. GameMode 에서 미션이 달성되면 현재 게임에서 동작하는 모든 폰을 멈추고 bGameCleared 속성을 true 로 설정한다. 그리고 ShowResultUI 함수를 호출해 결과 UI 를 띄운다.

 

ABGameState.h

...

UCLASS()
class ARENABATTLE_API AABGameState : public AGameStateBase
{
	GENERATED_BODY()

public:
	AABGameState();

public:
	
	...

	void SetGameCleared();
	bool IsGameCleared() const;

private:

	...

	UPROPERTY(Transient)
	bool bGameCleared;
};

 

ABGameState.cpp

#include "ABGameState.h"

AABGameState::AABGameState()
{
	TotalGameScore = 0;
	bGameCleared = false;
}

...

void AABGameState::SetGameCleared()
{
	bGameCleared = true;
}

bool AABGameState::IsGameCleared() const
{
	return bGameCleared;
}

 

ABGameMode.h

...

UCLASS()
class ARENABATTLE_API AABGameMode : public AGameModeBase
{

	...

private:

	...

    int32 ScoreToClear;
};

 

ABGameMode.cpp

...

AABGameMode::AABGameMode()
{
	...

	ScoreToClear = 2;
}

...

void AABGameMode::AddScore(class AABPlayerController* ScoredPlayer)
{

	...

	ABGameState->AddGameScore();

	if (GetScore() >= ScoreToClear)
	{
		ABGameState->SetGameCleared();

		for (FConstPawnIterator It = GetWorld()->GetPawnIterator(); It; ++It)
		{
			(*It)->TurnOff();
		}

		for (FConstPlayerControllerIterator It = GetWorld()->GetPlayerControllerIterator(); It; ++It)
		{
			const auto ABPlayerController = Cast<AABPlayerController>(It->Get());
			if (nullptr != ABPlayerController)
			{
				ABPlayerController->ShowResultUI();
			}
		}
	}
}

...

 

ABCharacter.cpp

void AABCharacter::SetCharacterState(ECharacterState NewState)
{

	...

		case ECharacterState::DEAD:
		{

			...

			GetWorld()->GetTimerManager().SetTimer(DeadTimerHandle, FTimerDelegate::CreateLambda([this]() -> void {
				if (bIsPlayer)
				{
					//ABPlayerController->RestartLevel();
					ABPlayerController->ShowResultUI();
				}
				else
				{
					Destroy();
				}
			}), DeadTimer, false);

		}
		break;
	}

}

UI 위젯의 NativeConstruct 함수는 AddToViewport 함수가 외부에서 호출될 때 UI 위젯이 초기화되면서 호출된다는 특징을 가진다.

그래서 플레이어 컨트롤러의 ShowResultUI 함수에서 AddToViewport 함수를 호출하기 전에 미리 UI 위젯이 게임스테이트의 정보를 읽어들일 수 있도록 바인딩을 설정하고 ABGameplayWidget 클래스에서 아직 구현하지 못한 btnRetryGame 버튼의 기능을 구현해 마무리한다.

 

ABGameplayResultWidget.h

#include "ArenaBattle.h"
#include "ABGameplayWidget.h"
#include "ABGameplayResultWidget.generated.h"

/**
 * 
 */
UCLASS()
class ARENABATTLE_API UABGameplayResultWidget : public UABGameplayWidget
{
	GENERATED_BODY()

public:
	void BindGameState(class AABGameState* GameState);
	
protected:
	virtual void NativeConstruct() override;

private:
	TWeakObjectPtr<class AABGameState> CurrentGameState;
};

 

ABGameplayResultWidget.cpp

#include "ABGameplayResultWidget.h"
#include "Components/TextBlock.h"
#include "ABGameState.h"

void UABGameplayResultWidget::BindGameState(AABGameState* GameState)
{
	ABCHECK(nullptr != GameState);
	CurrentGameState = GameState;
}

void UABGameplayResultWidget::NativeConstruct()
{
	Super::NativeConstruct();

	ABCHECK(CurrentGameState.IsValid());

	auto Result = Cast<UTextBlock>(GetWidgetFromName(TEXT("txtResult")));
	ABCHECK(nullptr != Result);
	Result->SetText(FText::FromString(CurrentGameState->IsGameCleared() ? TEXT("Mission Complete") : TEXT("Mission Failed")));

	auto TotalScore = Cast<UTextBlock>(GetWidgetFromName(TEXT("txtTotalScore")));
	ABCHECK(nullptr != TotalScore);
	TotalScore->SetText(FText::FromString(FString::FromInt(CurrentGameState->GetTotalGameScore())));
}

 

ABGameplayWidget.cpp

...

void UABGameplayWidget::OnRetryGameClicked()
{
	auto ABPlayerController = Cast<AABPlayerController>(GetOwningPlayer());
	ABCHECK(nullptr != ABPlayerController);
	ABPlayerController->RestartLevel();
}

 

ABPlayerController.cpp

...

#include "ABGameState.h"

...

void AABPlayerController::ShowResultUI()
{
	auto ABGameState = Cast<AABGameState>(UGameplayStatics::GetGameState(this));
	ABCHECK(nullptr != ABGameState);
	ResultWidget->BindGameState(ABGameState);

	ResultWidget->AddToViewport();
	ChangeInputMode(false);
}

 

이제 플레이어가 죽거나 조건을 만족하면 다음과 같은 결과 UI 가 뜬다.

Comments