관리 메뉴

KoreanFoodie's Study

이득우 언리얼 C++ 14 : 게임플레이의 제작 본문

Game Dev/Unreal C++ : Tutorial

이득우 언리얼 C++ 14 : 게임플레이의 제작

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

캐릭터의 스테이트 설정

AI 의 캐릭터 스테이트와 비슷하게, 플레이어의 캐릭터에도 스테이트 머신 모델을 구현해보자.

  • PREINIT 스테이트 : 캐릭터 생성 전의 스테이트. 애셋은 설정돼 있으나 캐릭터와 UI 를 숨겨둠
  • LOADING 스테이트 : 캐릭터 애셋을 로딩. 현재 컨트롤러가 AI 인지 플레이어인지 구분 가능. 플레이어 컨트롤러인 경우 애셋 로딩이 완료될 때까지 캐릭터를 조종하지 못하도록 입력을 비활성화.
  • READY 스테이트 : 캐릭터 애셋 로딩이 완료된 스테이트. 숨겨둔 캐릭터와 UI 를 보여주고, 대미지도 입는다. 플레이어 컨트롤러를 통해 캐릭터를 조종하고, AI 컨트롤러는 비헤이비어 트리 로직을 통해 캐릭터를 동작시킨다.
  • DEAD 스테이트 : HP 를 소진해 사망하면 죽는 애니메이션을 재생 / UI 끄기 / 충돌 기능 없애기 / 대미지 입지 않도록 설정. 컨트롤러가 플레이어인 경우 입력을 비활성하고 AI 인 경우 비헤이비어 트리 로직을 중지한다. 일정 시간이 지나면 플레이어는 재시작하고 AI 는 레벨에서 퇴장한다.

먼저 캐릭터의 스테이트를 담을 열거형을 UENUM(BlueprintType) 으로 만들고 uint8 으로 기반 유형(underlying type) 을 지정한다.

ArenaBattle.h

UENUM(BlueprintType)
enum class ECharacterState : uint8
{
	PREINIT,
	LOADING,
	READY,
	DEAD
};

이제 ABCharacter 에 해당 타입의 변수를 선언하고 기본 로직을 구성해보자.

ABCharacter.h

...

UCLASS()
class ARENABATTLE_API AABCharacter : public ACharacter
{
    GENERATED_BODY()

public:
    // Sets default values for this character's properties
    AABCharacter();
    void SetCharacterState(ECharacterState NewState);
    ECharacterState GetCharacterState() const;
    
    ...
    
private:
    
    ...
    
    int32 AssetIndex = 0;
    
    UPROPERTY(Transient, VisibleInstanceOnly, BlueprintReadOnly, Category = State, Meta = (AllowPrivateAccess = true))
    ECharacterState CurrentState;
    
    UPROPERTY(Transient, VisibleInstanceOnly, BlueprintReadOnly, Category = State, Meta = (AllowPrivateAccess = true))
    bool bIsPlayer;
    
    UPROPERTY()
    class AABAIController* ABAIController;
    
    UPROPERTY()
    class AABPlayerController* ABPlayerController;

캐릭터가 자신이 플레이어인지 NPC 인지 판별하는 건 BeginPlay 에서 이루어진다. 현재 설정상 플레이어도 전용 AI 컨트롤러가 자동으로 부착되므로 OnPossessedBy 함수가 두 번 호출되기 때문이다.
플레이어가 캐릭터를 조종하는 경우에는 임시로 4번 INDEX 의 캐릭터 애셋을 사용하고, 아닌 경우는 랜덤으로 하나를 골라 사용하게 만든다.

ABCharacter.cpp

...
#include "ABPlayerController.h"

...

AABCharacter::AABCharacter()
{
	...
    
	AssetIndex = 4;

	SetActorHiddenInGame(true);
	HPBarWidget->SetHiddenInGame(true);
	// bCanBeDamaged is private
	//bCanBeDamaged = false;
	SetCanBeDamaged(false);
}

...

void AABCharacter::BeginPlay()
{
	Super::BeginPlay();
	
	bIsPlayer = IsPlayerControlled();
	if (bIsPlayer)
	{
		ABPlayerController = Cast<AABPlayerController>(GetController());
		ABCHECK(nullptr != ABPlayerController);
	}
	else
	{
		ABAIController = Cast<AABAIController>(GetController());
		ABCHECK(nullptr != ABAIController);
	}

	auto DefaultSetting = GetDefault<UABCharacterSetting>();

	if (bIsPlayer)
	{
		AssetIndex = 4;
	}
	else
	{
		AssetIndex = FMath::RandRange(0, DefaultSetting->CharacterAssets.Num() - 1);
	}

	CharacterAssetToLoad = DefaultSetting->CharacterAssets[AssetIndex];
	auto ABGameInstance = Cast<UABGameInstance>(GetGameInstance());
	ABCHECK(nullptr != ABGameInstance);
	AssetStreamingHandle = ABGameInstance->StreamableManager.RequestAsyncLoad(CharacterAssetToLoad, FStreamableDelegate::CreateUObject(this, &AABCharacter::OnAssetLoadCompleted));
	SetCharacterState(ECharacterState::LOADING);
}

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

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

			auto ABPlayerState = Cast<AABPlayerState>(GetPlayerState());
			ABCHECK(nullptr != ABPlayerState);
			CharacterStat->SetNewLevel(ABPlayerState->GetCharacterLevel());
		}

		SetActorHiddenInGame(true);
		HPBarWidget->SetHiddenInGame(true);
		SetCanBeDamaged(false);
		break;
	}
	case ECharacterState::READY:
	{
		SetActorHiddenInGame(false);
		HPBarWidget->SetHiddenInGame(false);
		SetCanBeDamaged(true);

		CharacterStat->OnHPIsZero.AddLambda([this]() -> void {
			SetCharacterState(ECharacterState::DEAD);
		});

		auto CharacterWidget = Cast<UABCharacterWidget>(HPBarWidget->GetUserWidgetObject());
		ABCHECK(nullptr != CharacterWidget);
		CharacterWidget->BindCharacterStat(CharacterStat);

		if (bIsPlayer)
		{
			SetControlMode(EControlMode::DIABLO);
			GetCharacterMovement()->MaxWalkSpeed = 600.0f;
			EnableInput(ABPlayerController);
		}
		else
		{
			SetControlMode(EControlMode::NPC);
			GetCharacterMovement()->MaxWalkSpeed = 200.0f;
			ABAIController->RunAI();
		}

		break;
	}
	case ECharacterState::DEAD:
	{
		SetActorEnableCollision(false);
		GetMesh()->SetHiddenInGame(false);
		HPBarWidget->SetHiddenInGame(true);
		ABAnim->SetDeadAnim();
		SetCanBeDamaged(false);

		if (bIsPlayer)
		{
			DisableInput(ABPlayerController);
		}
		else
		{
			ABAIController->StopAI();
		}

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

		break;
	}

	}

}

ECharacterState AABCharacter::GetCharacterState() const
{
	return CurrentState;
}

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

명세에 맞게, 위와 같이 스테이트 기본 구성을 진행한다. 스테이트에 맞게 비헤이비어 트리 로직도 변경한다.

ABAIController.h

...

UCLASS()
class ARENABATTLE_API AABAIController : public AAIController
{
public:

	...

	void RunAI();
	void StopAI();
}


ABAIController.cpp

...

void AABAIController::OnPossess(APawn* InPawn)
{
	Super::OnPossess(InPawn);

}

void AABAIController::RunAI()
{
	if (UseBlackboard(BBAsset, Blackboard))
	{
		Blackboard->SetValueAsVector(HomePosKey, GetPawn()->GetActorLocation());
		if (!RunBehaviorTree(BTAsset))
		{
			ABLOG(Error, TEXT("AIController couldn't run behavior tree!"));
		}
	}
}

void AABAIController::StopAI()
{
	auto BehaviorTreeComponent = Cast<UBehaviorTreeComponent>(BrainComponent);
	if (nullptr != BehaviorTreeComponent)
	{
		BehaviorTreeComponent->StopTree(EBTStopMode::Safe);
	}
}

...

플레이어가 빙의할 때 발생하는 캐릭터의 PossessedBy 함수는 제거하고 대신 캐릭터의 READY 스테이트에서 이를 구현한다. AI 의 경우, 타이머를 발동시켜 사망한 이후에 처리할 로직도 구현한다.

ABCharacter.h

...

UCLASS()
class ARENABATTLE_API AABCharacter : public ACharacter
{

	...
    
private:

	...

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = State, Meta = (AllowPrivateAccess = true))
	float DeadTimer;

	FTimerHandle DeadTimerHandle = { };


}


ABCharacter.cpp

...

AABCharacter::AABCharacter()
{
	DeadTimer = 5.0f;
}

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

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

			auto ABPlayerState = Cast<AABPlayerState>(GetPlayerState());
			ABCHECK(nullptr != ABPlayerState);
			CharacterStat->SetNewLevel(ABPlayerState->GetCharacterLevel());
		}

		SetActorHiddenInGame(true);
		HPBarWidget->SetHiddenInGame(true);
		SetCanBeDamaged(false);
		break;
	}
	case ECharacterState::READY:
	{
		SetActorHiddenInGame(false);
		HPBarWidget->SetHiddenInGame(false);
		SetCanBeDamaged(true);

		CharacterStat->OnHPIsZero.AddLambda([this]() -> void {
			SetCharacterState(ECharacterState::DEAD);
		});

		auto CharacterWidget = Cast<UABCharacterWidget>(HPBarWidget->GetUserWidgetObject());
		ABCHECK(nullptr != CharacterWidget);
		CharacterWidget->BindCharacterStat(CharacterStat);

		if (bIsPlayer)
		{
			SetControlMode(EControlMode::DIABLO);
			GetCharacterMovement()->MaxWalkSpeed = 600.0f;
			EnableInput(ABPlayerController);
		}
		else
		{
			SetControlMode(EControlMode::NPC);
			GetCharacterMovement()->MaxWalkSpeed = 200.0f;
			ABAIController->RunAI();
		}

		break;
	}
	case ECharacterState::DEAD:
	{
		SetActorEnableCollision(false);
		GetMesh()->SetHiddenInGame(false);
		HPBarWidget->SetHiddenInGame(true);
		ABAnim->SetDeadAnim();
		SetCanBeDamaged(false);

		if (bIsPlayer)
		{
			DisableInput(ABPlayerController);
		}
		else
		{
			ABAIController->StopAI();
		}

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

		break;
	}

	}

}

플레이어일 경우와 아닌 경우를 약간 구분했다.

NPC 가 사망하면 AI 를 멈추고, 레벨에서 퇴장한다.

반면 플레이어가 죽는 경우에는 해당 레벨을 다시 시작한다.

플레이어 데이터와 UI 연동

이득우님의 책을 따라하면서 플레이어 데이터와 UI 를 연동시키려 했는데, 연동 과정에서 계속 에러/버그가 발생했다. 다양한 방법을 시도해봤지만 도저히 해결이 안되서, 언리얼 포럼에 질문도 올려보았다. 아직 답장은 없지만... 그래도 일단 UI 를 띄우는 것까지는 에러가 나지 않는다. 구현 코드 및 방법은 후술하겠지만, 크래시하는 부분부터는 크래시한다는 표시를 해 놓을 것이다.

먼저, PlayerState 를 상속받은 ABPlayerState 를 생성한다. PlayerState 는 FString 형의 PlayerName 속성과 float 형의 Score 를 이미 갖고 있다. 우리는 GameScore 와 CharacterLevel 속성을 추가한다.
ABPlayerState 클래스를 게임 모드의 PlayerStateClass 속성에 지정하면 엔진에서 플레이어 컨트롤러가 초기화될 때 함께 해당 클래스의 인스턴스를 생성하고 그 포인터 값을 플레이어 컨트롤러의 PlayerState 속성에 저장한다. 플레이어 컨트롤러의 구성을 완료하는 시점은 게임 모드의 PostLogin 함수이므로 이때 함께 ABPlayerState 의 초기화도 완료해 주는 것이 좋다.
ABPlayerState 의 설계와 게임 모드의 PostLogin 에서 새로운 플레이어 스테이트의 초기화를 진행한 코드는 다음과 같다.

ABPlayerState.h

#include "ArenaBattle.h"

...

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

public:
	AABPlayerState();

	int32 GetGameScore() const;
	int32 GetCharacterLevel() const;

	void InitPlayerData();

	FOnPlayerStateChangedDelegate OnPlayerStateChanged;

protected:
	UPROPERTY(Transient)
	int32 GameScore;

	UPROPERTY(Transient)
	int32 CharacterLevel;

}


ABPlayerState.cpp

AABPlayerState::AABPlayerState()
{
	CharacterLevel = 1;
	GameScore = 0;
}

int32 AABPlayerState::GetGameScore() const
{
	return GameScore;
}

int32 AABPlayerState::GetCharacterLevel() const
{
	return CharacterLevel;
}

void AABPlayerState::InitPlayerData()
{
	SetPlayerName(TEXT("Destiny"));
	CharacterLevel = 5;
	GameScore = 0;
}


ABGameMode.cpp

...

#include "ABPlayerState.h"

AABGameMode::AABGameMode()
{
	...
	
    PlayerStateClass = AABPlayerState::StaticClass();
}

void AABGameMode::PostLogin(APlayerController* NewPlayer)
{
	Super::PostLogin(NewPlayer);

	auto ABPlayerState = Cast<AABPlayerState>(NewPlayer->PlayerState);
	ABCHECK(nullptr != ABPlayerState);
	ABPlayerState->InitPlayerData();
}

플레이어의 레벨 정보는 실제로 캐릭터에 반영해야 한다. 플레이어 컨트롤러가 캐릭터에 빙의할 때 캐릭터의 PlayerState 속성에 플레이어 스테이트의 포인터를 저장하므로 캐릭터에서도 해당 플레이어 스테이트 정보를 바로 가져올 수 있다.

ABCharacter.cpp

...
#include "ABPlayerState.h"

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

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

			auto ABPlayerState = Cast<AABPlayerState>(GetPlayerState());
			ABCHECK(nullptr != ABPlayerState);
			CharacterStat->SetNewLevel(ABPlayerState->GetCharacterLevel());
		}

		SetActorHiddenInGame(true);
		HPBarWidget->SetHiddenInGame(true);
		SetCanBeDamaged(false);
		break;
	}

	...

}

GetPlayerState() 로 스테이트를 불러온다. 플레이를 누르면, 우리가 조종하는 캐릭터는 5 레벨로 설정된다.

플레이어 데이터를 설정하고 나면, 이번에는 HUD(Head Up Display) UI 를 화면에 부착하고 캐릭터 정보를 표시하도록 구현해 본다. 예제 파일의 Resource > Chapter14 > Book > UI 폴더의 UI_HUD_uasset 파일을 Content > Book > UI 폴더에 복사한다.
그 후, 복사한 UI 위젯을 관리하기 위해 UserWidget 을 기본 클래스로 하는 ABHUDWidget 이라는 이름의 클래스를 생성한다.


그 후, UI_HUD 에 들어가 그래프 > 클래스 세팅에서 부모 클래스를 ABHUDWidget 으로 설정한다.


플레이어 컨트롤러에서 해당 위젯을 생성한 후 화면에 띄워보자.

ABPlayerController.h

...

UCLASS()
class ARENABATTLE_API AABPlayerController : public APlayerController
{

public:
	AABPlayerController();

	...
    
	class UABHUDWidget* GetHUDWidget() const;
    
protected:
	
    ...
    
	UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = UI)
	TSubclassOf<class UABHUDWidget> HUDWidgetClass;
	
private:
	UPROPERTY()
	class UABHUDWidget* HUDWidget;


ABPlayerController.cpp

...
#include "ABPlayerState.h"

AABPlayerController::AABPlayerController()
{
	static ConstructorHelpers::FClassFinder<UABHUDWidget> UI_HUD_C(TEXT("/Game/Book/UI/UI_HUD.UI_HUD_C"));
	if (UI_HUD_C.Succeeded())
	{
		HUDWidgetClass = UI_HUD_C.Class;
	}

}

...

void AABPlayerController::BeginPlay()
{
	...
    
	HUDWidget = CreateWidget<UABHUDWidget>(this, HUDWidgetClass);
	HUDWidget->AddToViewport();
}

UABHUDWidget* AABPlayerController::GetHUDWidget() const
{
	return HUDWidget;
}


이제 UI 가 잘 뜰 것이다!


... 여기까지가 524쪽의 내용이고, 정상적으로 작동한다. 하지만 Chapter 14 뒷부분은 계속 크래시가 발생해서... 여기서 동작 스샷을 찍지는 못했다. 추후 엔진 재설치 후 다시 시도해 볼 예정이다.
아마 모듈 문제 혹은 엔진 업데이트로 인한 문제일 것이라고 생각한다... 아래 부분에 일단 책에 나온 코드를 옮겨놓았다.
다행히 문제를 해결했다. 이전에 선언만 해놓고 정의를 제대로 구현하지 않았던 함수가 문제였다.



해당 UI 에는 플레이어의 데이터와 캐릭터의 HP 데이터 정보가 함께 표시된다. 따라서 플레이어 스테이트와 캐릭터 스탯 컴포넌트 정보를 모두 해당 HUD에 연동해야 한다.

ABPlayerState.h

...

DECLARE_MULTICAST_DELEGATE(FOnPlayerStateChangedDelegate);


...

UCLASS()
class ARENABATTLE_API AABPlayerState : public APlayerState
{

public:

	...

	FOnPlayerStateChangedDelegate OnPlayerStateChanged;
}


ABHUDWidget.h

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

#pragma once

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

/**
 * 
 */
UCLASS()
class ARENABATTLE_API UABHUDWidget : public UUserWidget
{
	GENERATED_BODY()
	
public:
	void BindCharacterStat(class UABCharacterStatComponent* CharacterStat);
	void BindPlayerState(class AABPlayerState* PlayerState);

protected:
	virtual void NativeConstruct() override;
	void UpdateCharacterStat();
	void UpdatePlayerState();
	
private:
	TWeakObjectPtr<class UABCharacterStatComponent> CurrentCharacterStat;
	TWeakObjectPtr<class AABPlayerState> CurrentPlayerState;

	UPROPERTY()
	class UProgressBar* HPBar;

	UPROPERTY()
	class UProgressBar* ExpBar;

	UPROPERTY()
	class UTextBlock* PlayerName;

	UPROPERTY()
	class UTextBlock* PlayerLevel;

	UPROPERTY()
	class UTextBlock* CurrentScore;

	UPROPERTY()
	class UTextBlock* HighScore;
};


ABHUDWidget.cpp

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

#include "ABHUDWidget.h"
#include "Components/ProgressBar.h"
#include "Components/TextBlock.h"
#include "ABCharacterStatComponent.h"
#include "ABPlayerState.h"

void UABHUDWidget::BindCharacterStat(UABCharacterStatComponent* CharacterStat)
{
	ABCHECK(nullptr != CharacterStat);
	CurrentCharacterStat = CharacterStat;
	CharacterStat->OnHPChanged.AddUObject(this, &UABHUDWidget::UpdateCharacterStat);
}

void UABHUDWidget::BindPlayerState(AABPlayerState* PlayerState)
{
	ABCHECK(nullptr != PlayerState);
	CurrentPlayerState = PlayerState;
	PlayerState->OnPlayerStateChanged.AddUObject(this, &UABHUDWidget::UpdatePlayerState);
}

void UABHUDWidget::NativeConstruct()
{
	Super::NativeConstruct();
	HPBar = Cast<UProgressBar>(GetWidgetFromName(TEXT("pbHP")));
	ABCHECK(nullptr != HPBar);

	ExpBar = Cast<UProgressBar>(GetWidgetFromName(TEXT("pbExp")));
	ABCHECK(nullptr != ExpBar);

	PlayerName = Cast<UTextBlock>(GetWidgetFromName(TEXT("txtPlayerName")));
	ABCHECK(nullptr != PlayerName);

	PlayerLevel = Cast<UTextBlock>(GetWidgetFromName(TEXT("txtLevel")));
	ABCHECK(nullptr != PlayerLevel);

	CurrentScore = Cast<UTextBlock>(GetWidgetFromName(TEXT("txtCurrentScore")));
	ABCHECK(nullptr != CurrentScore);

	HighScore = Cast<UTextBlock>(GetWidgetFromName(TEXT("txtHighScore")));
	ABCHECK(nullptr != HighScore);
}

void UABHUDWidget::UpdateCharacterStat()
{
	ABCHECK(CurrentCharacterStat.IsValid());

	HPBar->SetPercent(CurrentCharacterStat->GetHPRatio());
}

void UABHUDWidget::UpdatePlayerState()
{
	ABCHECK(CurrentPlayerState.IsValid());

	ExpBar->SetPercent(CurrentPlayerState->GetExpRatio());
	PlayerName->SetText(FText::FromString(CurrentPlayerState->GetPlayerName()));
	PlayerLevel->SetText(FText::FromString(FString::FromInt(CurrentPlayerState->GetCharacterLevel())));
	CurrentScore->SetText(FText::FromString(FString::FromInt(CurrentPlayerState->GetGameScore())));
}

ABHUDWidget 에서 플레이어 이름, 레벨, 점수 등의 정보를 CurrentCharacterStat 과 CurrentPlayerState 로부터 받아온다.
코드를 완성하면 플레이어 컨트롤러에서 HUD 위젯과 플레이어 스테이트를 연결하고, 캐릭터에서 HUD 위젯과 캐릭터 스탯 컴포넌트를 연결한다.

ABPlayerController.cpp

...

#include "ABPlayerState.h"

...

void AABPlayerController::BeginPlay()
{

	...

	ABPlayerState = Cast<AABPlayerState>(PlayerState);
	ABCHECK(nullptr != ABPlayerState);
	HUDWidget->BindPlayerState(ABPlayerState);
	ABPlayerState->OnPlayerStateChanged.Broadcast();
}


ABCharacter.cpp

...

#include "ABHUDWidget.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());
			}

			SetActorHiddenInGame(true);
			HPBarWidget->SetHiddenInGame(true);
			SetCanBeDamaged(false);
		}
		break;
        
        ...
        
		}
	}
	...
}

...

이제 플레이어 정보와 캐릭터 스탯 정보가 HUD 위젯에 연동된다.

이어서 플레이어 데이터에 경험치 정보를 표시하도록 기능을 추가한다. 캐릭터 스탯에 NPC 를 위한 경험치 값을 설정하고, 플레이어 스테이트에는 플레이어의 경험치 데이터를 보관하도록 설계를 확장해야 한다. 그리고 캐릭터가 사망할 때 NPC 가 플레이어에게 죽는지 검사하고, 해당 플레이어 컨트롤러를 통해 플레이어 스테이트를 업데이트 시키는 로직을 추가한다.
대미지 프레임워크에서 플레이어 컨트롤러의 정보는 가해자(Instigator) 인자로 전달되므로 이를 사용하면 된다.

ABPlayerState.h

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

	...

	float GetExpRatio() const;
	bool AddExp(int32 IncomeExp);

	...

protected:

	...

	UPROPERTY(Transient)
	int32 Exp;

private:
	void SetCharacterLevel(int32 NewCharacterLevel);
	struct FABCharacterData* CurrentStatData;
};


ABPlyaerState.cpp

...
#include "ABGameInstance.h"

AABPlayerState::AABPlayerState()
{
	CharacterLevel = 1;
	GameScore = 0;
	Exp = 0;
}

...

float AABPlayerState::GetExpRatio() const
{
	if (CurrentStatData->NextExp <= KINDA_SMALL_NUMBER)
		return 0;

	float Result = (float)Exp / (float)CurrentStatData->NextExp;
	ABLOG(Warning, TEXT("Ratio : %f, Current : %d, Next : %d"), Result, Exp, CurrentStatData->NextExp);
	return Result;
}

bool AABPlayerState::AddExp(int32 IncomeExp)
{
	if (CurrentStatData->NextExp == -1)
		return false;

	bool DidLevelUp = false;
	Exp = Exp + IncomeExp;
	if (Exp >= CurrentStatData->NextExp)
	{
		Exp -= CurrentStatData->NextExp;
		SetCharacterLevel(CharacterLevel + 1);
		DidLevelUp = true;
	}

	OnPlayerStateChanged.Broadcast();
	return DidLevelUp;
}

void AABPlayerState::AddGameScore()
{
	GameScore++;
	OnPlayerStateChanged.Broadcast();
}

void AABPlayerState::InitPlayerData()
{
	SetPlayerName(TEXT("Destiny"));
	SetCharacterLevel(5);
	GameScore = 0;
	Exp = 0;
}

void AABPlayerState::SetCharacterLevel(int32 NewCharacterLevel)
{
	auto ABGameInstance = Cast<UABGameInstance>(GetGameInstance());
	ABCHECK(nullptr != ABGameInstance);

	CurrentStatData = ABGameInstance->GetABCharacterData(NewCharacterLevel);
	ABCHECK(nullptr != CurrentStatData);

	CharacterLevel = NewCharacterLevel;
}


ABHUDWidget.cpp

...

void UABHUDWidget::UpdatePlayerState()
{
	...
    
	ExpBar->SetPercent(CurrentPlayerState->GetExpRatio());

	...
}


ABCharacterStatComponent.h

UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class ARENABATTLE_API UABCharacterStatComponent : public UActorComponent
{
	GENERATED_BODY()

public:	
	
	...

	int32 GetDropExp() const;

	...
};


ABCharacterStatComponent.cpp

...

int32 UABCharacterStatComponent::GetDropExp() const
{
	return CurrentStatData->DropExp;
}

...


ABCharacter.h

...
UCLASS()
class ARENABATTLE_API AABCharacter : public ACharacter
{
	...
    
    public:
        int32 GetExp() const;
 
 	...
    
}


ABPlayerController.h

...

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

public:

	...

	void NPCKill(class AABCharacter* KilledNPC) const;

	...

private:

	UPROPERTY()
	class AABPlayerState* ABPlayerState;
};


ABPlayerController.cpp

...
#include "ABCharacter.h"

void AABPlayerController::BeginPlay()
{

	...

	ABPlayerState = Cast<AABPlayerState>(PlayerState);

	...
}

...

void AABPlayerController::NPCKill(class AABCharacter* KilledNPC) const
{
	ABPlayerState->AddExp(KilledNPC->GetExp());
}


ABCharacter.cpp

...

int32 AABCharacter::GetExp() const
{
	return CharacterStat->GetDropExp();
}

...

float AABCharacter::TakeDamage(float DamageAmount, struct FDamageEvent const& DamageEvent, class AController* EventInstigator, AActor* DamageCauser)
{
	float FinalDamage = Super::TakeDamage(DamageAmount, DamageEvent, EventInstigator, DamageCauser);
	ABLOG(Warning, TEXT("Actor : %s took Damage : %f"), *GetName(), FinalDamage);

	CharacterStat->SetDamage(FinalDamage);
	if (CurrentState == ECharacterState::DEAD)
	{
		if (EventInstigator->IsPlayerController())
		{
			auto PlayerController = Cast<AABPlayerController>(EventInstigator);
			ABCHECK(nullptr != PlayerController, 0.0f);
			PlayerController->NPCKill(this);
		}
	}
	return FinalDamage;
}

해당 코드를 적용하고 NPC 를 처치하면 이제 경험치에 관련된 항목이 추가로 업데이트 된다.


게임 데이터의 관리

게임 스코어 부분을 구현해 보자. 먼저 현재 레벨을 GamePlay 라는 이름으로 저장한다.
새로운 레벨에서 테스트를 위해 첫 섹션에 배치한 캐릭터와 상자를 모두 제거한다. GamePlay 를 기본 맵으로 설정하고, NavMeshBoundsVolume 의 영역을 확대한다.


멀티플레이 콘텐츠일 경우, 플레이어에 설정된 데이터 외에도 게임의 데이터를 관리해야 한다. 이를 위해 게임 스테이트라는 클래스를 사용한다.
GameStatBase 클래스를 부모로 한 ABGameState 라는 이름의 C++ 클래스를 생성한다.


ABGameState.h

#pragma once

#include "ArenaBattle.h"
#include "GameFramework/GameStateBase.h"
#include "ABGameState.generated.h"

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

public:
	AABGameState();

public:
	int32 GetTotalGameScore() const;
	void AddGameScore();

private:
	UPROPERTY(Transient)
	int32 TotalGameScore;	
};


ABGameState.cpp

#include "ABGameState.h"

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

int32 AABGameState::GetTotalGameScore() const
{
	return TotalGameScore;
}

void AABGameState::AddGameScore()
{
	TotalGameScore++;
}


ABGameMode.cpp

...

#include "ABGameState.h"

AABGameMode::AABGameMode()
{
	DefaultPawnClass = AABCharacter::StaticClass();
	PlayerControllerClass = AABPlayerController::StaticClass();
	PlayerStateClass = AABPlayerState::StaticClass();
	GameStateClass = AABGameState::StaticClass();
}

...


이제 섹션 액터의 로직으로 이동해 섹션에서 생성한 NPC 가 플레이어에 의해 제거되면 이를 감지해 섹션 액터의 스테이트를 COMPLETE 로 변경하는 기능을 추가한다. NPC 가 제거될 때 마지막으로 대미지를 입힌 컨트롤러의 기록은 LastHitBy 속성에 저장된다. 이를 사용하면 액터가 제거될 때 마지막에 피격을 가한 플레이어의 정보를 바로 얻어올 수 있다. (Instigator 를 검사하는 방식보다 소멸될 때 LastHitBy 를 사용하는 것이 더 효율적이다.)

ABSection.h

UCLASS()
class ARENABATTLE_API AABSection : public AActor
{
	...
    
public:

	UFUNCTION()
	void OnKeyNPCDestroyed(AActor* DestroyedActor);
    
	...
}


ABSection.cpp

#include "ABPlayerController.h"

...

void AABSection::OnNPCSpawn()
{
	auto KeyNPC = GetWorld()->SpawnActor<AABCharacter>(GetActorLocation() + FVector::UpVector * 88.0f, FRotator::ZeroRotator);
	if (nullptr != KeyNPC)
	{
		KeyNPC->OnDestroyed.AddDynamic(this, &AABSection::OnKeyNPCDestroyed);
	}
}

void AABSection::OnKeyNPCDestroyed(AActor* DestroyedActor)
{
	auto ABCharacter = Cast<AABCharacter>(DestroyedActor);
	ABCHECK(nullptr != ABCharacter);

	auto ABPlayerController = Cast<AABPlayerController>(ABCharacter->LastHitBy);
	ABCHECK(nullptr != ABPlayerController);

	auto ABGameMode = Cast<AABGameMode>(GetWorld()->GetAuthGameMode());
	ABCHECK(nullptr != ABGameMode);
	ABGameMode->AddScore(ABPlayerController);

	SetState(ESectionState::COMPLETE);
}

NPC 를 제거해 섹션을 클리어하면 게임 모드에게 스코어를 올리라는 명령을 내린다. 여기서 마지막 피격을 가한 플레이어 컨트롤러 정보를 함께 넘겨 해당 플레이어 스테이트의 스코어를 높이고, 전체 스코어도 올린다.
현재 게임에 참여 중인 플레이어 컨트롤러의 목록은 월드에서 제공하는 GetPlayerControllerIterator 를 사용해 얻어올 수 있다.

ABPlayerState.h

...

UCLASS()
class ARENABATTLE_API AABPlayerState : public APlayerState
{
	GENERATED_BODY()
	
public:
	
    ...
    
	void AddGameScore();
    
    ...
    
}


ABPlayerState.cpp

...

void AABPlayerState::AddGameScore()
{
	GameScore++;
	OnPlayerStateChanged.Broadcast();
}

...


ABPlayerController.h

...

UCLASS()
class ARENABATTLE_API AABPlayerController : public APlayerController
{

public:

	...
    
	void AddGameScore() const;

	...
}


ABPlayerController.cpp

...

void AABPlayerController::AddGameScore() const
{
	ABPlayerState->AddGameScore();
}

...


ABGameMode.h

#pragma once

#include "ArenaBattle.h"
#include "GameFramework/GameModeBase.h"
#include "ABGameMode.generated.h"

/**
 * 
 */
UCLASS()
class ARENABATTLE_API AABGameMode : public AGameModeBase
{
	GENERATED_BODY()

public:
    AABGameMode();

    virtual void PostInitializeComponents() override;
    virtual void PostLogin(APlayerController* NewPlayer) override;
    void AddScore(class AABPlayerController* ScoredPlayer);

private:
    UPROPERTY()
    class AABGameState* ABGameState;
};


ABGameMode.cpp

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


#include "ABGameMode.h"
#include "ABCharacter.h"
#include "ABPlayercontroller.h"
#include "ABPlayerState.h"
#include "ABGameState.h"

AABGameMode::AABGameMode()
{
	DefaultPawnClass = AABCharacter::StaticClass();
	PlayerControllerClass = AABPlayerController::StaticClass();
	PlayerStateClass = AABPlayerState::StaticClass();
	GameStateClass = AABGameState::StaticClass();
}

void AABGameMode::PostInitializeComponents()
{
	Super::PostInitializeComponents();
	ABGameState = Cast<AABGameState>(GameState);
}

void AABGameMode::PostLogin(APlayerController* NewPlayer)
{
	Super::PostLogin(NewPlayer);

	auto ABPlayerState = Cast<AABPlayerState>(NewPlayer->PlayerState);
	ABCHECK(nullptr != ABPlayerState);
	ABPlayerState->InitPlayerData();
}

void AABGameMode::AddScore(class AABPlayerController* ScoredPlayer)
{
	for (FConstPlayerControllerIterator It = GetWorld()->GetPlayerControllerIterator(); It; ++It)
	{
		const auto ABPlayerController = Cast<AABPlayerController>(It->Get());
		if ((nullptr != ABPlayerController) && (ScoredPlayer == ABPlayerController))
		{
			ABPlayerController->AddGameScore();
			break;
		}
	}
	ABGameState->AddGameScore();
}


ABSection.cpp

#include "ABGameMode.h"

...

void AABSection::OnKeyNPCDestroyed(AActor* DestroyedActor)
{
	auto ABCharacter = Cast<AABCharacter>(DestroyedActor);
	ABCHECK(nullptr != ABCharacter);

	auto ABPlayerController = Cast<AABPlayerController>(ABCharacter->LastHitBy);
	ABCHECK(nullptr != ABPlayerController);

	auto ABGameMode = Cast<AABGameMode>(GetWorld()->GetAuthGameMode());
	ABCHECK(nullptr != ABGameMode);
	ABGameMode->AddScore(ABPlayerController);

	SetState(ESectionState::COMPLETE);
}


이제 NPC 가 제거되면 우측 상단의 스코어가 하나씩 올라간다!


참고

해당 ArenaBattle 프로젝트는 다음과 같은 두 가지 맹점이 있다 :

  1. NPC 가 NPC 를 죽일 수 있다 (NPC 가 NPC 를 죽일 경우, Section State 가 바뀌지 않음)
  2. NPC 스폰시, 중앙에 플레이어가 있으면 NPC 가 스폰이 안돼서 Section State 가 변경이 안된다.

실습 예제이기 때문에 큰 상관은 없으나, 추후 실제 게임 제작에서는 참고할 만한 부분이다.

7 Comments
  • 프로필사진 익명 2022.04.21 08:53 비밀댓글입니다
  • 프로필사진 머니덕 2022.04.21 09:28 신고 음.. 아마 처치시 죽는 함수였던것 같은데요, 사실 그 함수가 문제라기보다는 정의만 해놓고 제대로 구현하지 않아 생긴 문제였던 것 같습니다!
  • 프로필사진 익명 2022.04.21 14:21 비밀댓글입니다
  • 프로필사진 머니덕 2022.04.21 14:30 신고 혹시 에러메시지를 올려주실수 있나요? 공개 댓글로 올려주시면 편할 것 같습니다
  • 프로필사진 devX 2022.04.21 22:30 Unhandled Exception: EXCEPTION_ACCESS_VIOLATION reading address 0x0000000000000000
  • 프로필사진 devX 2022.04.21 22:31 PlayerName->SetText(FText::FromString(CurrentPlayerState->GetPlayerName()));
    PlayerLevel->SetText(FText::FromString(FString::FromInt(CurrentPlayerState->GetCharacterLevel())));
    CurrentScore->SetText(FText::FromString(FString::FromInt(CurrentPlayerState->GetGameScore())));

    이 부분에서 크래시가 발생합니다
  • 프로필사진 머니덕 2022.04.21 23:25 신고 음 제가 함수를 거슬러 올라가며 생각을 해 보았는데요, GetPlayerName( ) 함수는 APlayerState 클래스에 정의되어 있고, 해당 함수는 결국 APlayerState 내부에 정의된 private 멤버 변수인 FString 타입의 변수 PlayerNamePrivate 를 읽게 됩니다.

    그런데 PlayerNamePrivate 변수는 SetPlayerName 함수가 Call 되어야만 세팅이 가능하단 말이죠? 이 부분은 ABPlayerState::InitPlayerData( ) 에서 불리도록 되어 있는데... 만약 세이브된 게임을 거치지 않으면, ABSaveGame 이 nullptr 이기 때문에 ABSaveGame = GetMutableDefault<UABSaveGame>( ); 이 실행됩니다. 즉, SetPlayerName( ) 이 불릴때 PlayerName 에 제대로 된 값이 들어가지 않는게 아닐까... 그런 생각이 드네요. 일단 14단원까지 작동하는 소스코드를 받아서 한번 비교해 보세요!

    https://github.com/hashnut/Unreal_CPP_Practice_Lee/tree/5e1ce2ef4f7bd33c06414a0a52b800361fc640fb
댓글쓰기 폼