개발 이야기 안하는 개발자

언리얼5_2_5 : 언리얼 게임의 완성 본문

Unreal/이득우의 언리얼 프로그래밍

언리얼5_2_5 : 언리얼 게임의 완성

07e 2024. 3. 28. 21:35
반응형

헤드업 디스플레이

HUD는 플레이어 컨트롤러에 의해 제작되고 관리되는 UI 객체이다.

HUD의 구현은 위젯을 생성하고 이를 플레이어 뷰포트에 띄우는 과정으로 생성된다.

이렇게 만들어진 위젯은 자신을 소유한 플레이어 컨트롤러에 접근할 수 있다.

 

위 이미지는 각각 초기화 되는 기능 순서이다.

CreateWidget을 호출해서 제작하고 그럼 UI가 NativeOnInitialized 가 호출된다.

이후 AddtoViewPort를 호출하면 NatvieConstruct 가 호출되는 방식이다.

 

CharacterStatComponent.cpp에서 값을 초기화를 먼저 하려면 InitializeComponent부터 호출해야 한다.

protected:
	virtual void InitializeComponent() override;
    
    ...
    
    
.cpp
생성자

	bWantsInitializeComponent = true;
    ....
    
...
void UABCharacterStatComponent::InitializeComponent()
{
	Super::InitializeComponent();

	SetLevelStat(CurrentLevel);
	SetHp(BaseStat.MaxHp);
}

InitializeComponent를 호출함으로써 액터보다 먼저 값을 정의하게 되었다.

사용하려면 bWantsInitializeComponent를 true로 지정해야 한다.

 

CharacterPlayer가 HUDWidget을 셋업할 예정이다.

class ARENABATTLE_API IABCharacterHUDInterface
{
	GENERATED_BODY()

	// Add interface functions to this class. This is the class that will be inherited to implement this interface.
public:
	virtual void SetupHUDWidget(class UABHUDWidget* InHUDWidget) = 0;
};

인터페이를 제작하고 Player가 이를 상속받고 이를 구현한다.

 

그 전에 HUD가 Stat의 정보를 가져올 탠데 StatComponent에 값을 가져오는 메소드나 델리게이트가 없어서 이것 부터 정의한다.

DECLARE_MULTICAST_DELEGATE_OneParam(FOnHpChangedDelegate, float /*CurrentHp*/);
DECLARE_MULTICAST_DELEGATE_TwoParams(FOnStatChangedDelegate, const FABCharacterStat& /*BaseStat*/, const FABCharacterStat& /*ModifierStat*/);

...
	FORCEINLINE void SetBaseStat(const FABCharacterStat& InBaseStat) { BaseStat = InBaseStat; OnStatChanged.Broadcast(GetBaseStat(), GetModifierStat()); }
	FORCEINLINE void SetModifierStat(const FABCharacterStat& InModifierStat) { ModifierStat = InModifierStat; OnStatChanged.Broadcast(GetBaseStat(), GetModifierStat()); }

 

 

void AABCharacterPlayer::SetupHUDWidget(UABHUDWidget* InHUDWidget)
{
	if (InHUDWidget)
	{
		InHUDWidget->UpdateStat(Stat->GetBaseStat(), Stat->GetModifierStat());
		InHUDWidget->UpdateHpBar(Stat->GetCurrentHp());

		Stat->OnStatChanged.AddUObject(InHUDWidget, &UABHUDWidget::UpdateStat);
		Stat->OnHpChanged.AddUObject(InHUDWidget, &UABHUDWidget::UpdateHpBar);
	}
}

그럼 Stat의 변화에 맞춰 HUD의 변수값이 변경될 것이다.

 

HUD에 사용되는 Widget을 제작한다.

class ARENABATTLE_API UABHUDWidget : public UUserWidget
{
	GENERATED_BODY()

public:
	UABHUDWidget(const FObjectInitializer& ObjectInitializer);

public:
	void UpdateStat(const FABCharacterStat& BaseStat, const FABCharacterStat& ModifierStat);
	void UpdateHpBar(float NewCurrentHp);

protected:
	virtual void NativeConstruct() override;

protected:
	UPROPERTY()
	TObjectPtr<class UABHpBarWidget> HpBar;

	UPROPERTY()
	TObjectPtr<class UABCharacterStatWidget> CharacterStat;
};

가져올 값인 Stat을, 적용할 HpBar를 모두 가지고 온다.

 

UABHUDWidget::UABHUDWidget(const FObjectInitializer& ObjectInitializer) : Super(ObjectInitializer)
{
}

void UABHUDWidget::UpdateStat(const FABCharacterStat& BaseStat, const FABCharacterStat& ModifierStat)
{
	FABCharacterStat TotalStat = BaseStat + ModifierStat; //operator+ 구조체
	HpBar->SetMaxHp(TotalStat.MaxHp);

	CharacterStat->UpdateStat(BaseStat, ModifierStat);
}

void UABHUDWidget::UpdateHpBar(float NewCurrentHp)
{
	HpBar->UpdateHpBar(NewCurrentHp);
}

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

	HpBar = Cast<UABHpBarWidget>(GetWidgetFromName(TEXT("WidgetHpBar")));
	ensure(HpBar);

	CharacterStat = Cast<UABCharacterStatWidget>(GetWidgetFromName(TEXT("WidgetCharacterStat")));
	ensure(CharacterStat);

	IABCharacterHUDInterface* HUDPawn = Cast<IABCharacterHUDInterface>(GetOwningPlayerPawn());
	if (HUDPawn)
	{
		HUDPawn->SetupHUDWidget(this);
	}

}

 

GetOwningPlayer()을 통해서 HUD를 보유하고 있는 Controller에 접근할 수 있다.

GetOwningPlayerPawn()을 통해서 소유한 Pawn도 가져올 수 있다.

해당 폰을 인터페이스로 Cast해서 가져온다음에 Setup을 진행하면 된다.

 

hud가 PlayerController 하위에 있도록 PlayerController를 수정한다.

class ARENABATTLE_API AABPlayerController : public APlayerController
{
	GENERATED_BODY()
	
public:
	AABPlayerController();
	
protected:
	virtual void BeginPlay() override;
	
// HUD Section
protected:
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = HUD)
	TSubclassOf<class UABHUDWidget> ABHUDWidgetClass;

	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = HUD)
	TObjectPtr<class UABHUDWidget> ABHUDWidget;
};

HUD 위젯 클래스와 위젯을 모두 가져온다.

 

AABPlayerController::AABPlayerController()
{
	static ConstructorHelpers::FClassFinder<UABHUDWidget> ABHUDWidgetRef(TEXT("/Game/ArenaBattle/UI/WBP_ABHUD.WBP_ABHUD_C"));
	if (ABHUDWidgetRef.Class)
	{
		ABHUDWidgetClass = ABHUDWidgetRef.Class;
	}
}

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

	FInputModeGameOnly GameOnlyInputMode;
	SetInputMode(GameOnlyInputMode);

	ABHUDWidget = CreateWidget<UABHUDWidget>(this, ABHUDWidgetClass);
	if (ABHUDWidget)
	{
		ABHUDWidget->AddToViewport();
	}
}

HUD를 가져오고 실행하면 위젯을 CreateWidget을 통해 제작한다.

AddToViewport를 통해서 화면에 띄운다.

이때 StatComponent쪽에서 먼저 Beginplay보다 먼저 데이터를 초기화 했기 때문에 AddToViewPort를 해도 정상적으로 데이터가 출력될 것이다.

 

이렇게 하면 화면에 체력바가 나온다.

이것 이상을 캐릭터의 상태를 화면에 띄울 것이다.

 

스탯을 띄울 UserWidget 클래스를 제작한다.

class ARENABATTLE_API UABCharacterStatWidget : public UUserWidget
{
	GENERATED_BODY()

protected:
	virtual void NativeConstruct() override;
	
public:
	void UpdateStat(const FABCharacterStat& BaseStat, const FABCharacterStat& ModifierStat);

private:
	UPROPERTY()
	TMap<FName, class UTextBlock*> BaseLookup;

	UPROPERTY()
	TMap<FName, class UTextBlock*> ModifierLookup;
};

TMap형식으로 Base값과 Modifier 값을 띄우도록 로직을 구현한다.

 

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

	for (TFieldIterator<FNumericProperty> PropIt(FABCharacterStat::StaticStruct()); PropIt; ++PropIt)
	{
		const FName PropKey(PropIt->GetName());
		const FName TextBaseControlName = *FString::Printf(TEXT("Txt%sBase"), *PropIt->GetName());
		const FName TextModifierControlName = *FString::Printf(TEXT("Txt%sModifier"), *PropIt->GetName());

		UTextBlock* BaseTextBlock = Cast<UTextBlock>(GetWidgetFromName(TextBaseControlName));
		if (BaseTextBlock)
		{
			BaseLookup.Add(PropKey, BaseTextBlock);
		}

		UTextBlock* ModifierTextBlock = Cast<UTextBlock>(GetWidgetFromName(TextModifierControlName));
		if (ModifierTextBlock)
		{
			ModifierLookup.Add(PropKey, ModifierTextBlock);
		}
	}
}

void UABCharacterStatWidget::UpdateStat(const FABCharacterStat& BaseStat, const FABCharacterStat& ModifierStat)
{
	for (TFieldIterator<FNumericProperty> PropIt(FABCharacterStat::StaticStruct()); PropIt; ++PropIt)
	{
		const FName PropKey(PropIt->GetName());

		float BaseData = 0.0f;
		PropIt->GetValue_InContainer((const void*)&BaseStat, &BaseData);
		float ModifierData = 0.0f;
		PropIt->GetValue_InContainer((const void*)&ModifierStat, &ModifierData);

		UTextBlock** BaseTextBlockPtr = BaseLookup.Find(PropKey);
		if (BaseTextBlockPtr)
		{
			(*BaseTextBlockPtr)->SetText(FText::FromString(FString::SanitizeFloat(BaseData)));
		}

		UTextBlock** ModifierTextBlockPtr = ModifierLookup.Find(PropKey);
		if (ModifierTextBlockPtr)
		{
			(*ModifierTextBlockPtr)->SetText(FText::FromString(FString::SanitizeFloat(ModifierData)));
		}
	}
}

구조체에 있는 값들을 반복자를 활용해서 데이터를 뽑아 온다.

필요한 데이터를 FString으로 합쳐서 이름을 변환하고, 이를 가져온다.

가져온 Key값들에 대해 뭔가 스텟이 변경이 된다면 바로 값들을 수정하도록 로직을 수정한다.

 

위 코드를 블루프린트로 만들고 이름 조심하며 제작한다 (이름이 틀리면안됨)

 

이후 Hud에 체력바와 StatWidget을 가져오면 된다.

 

 

 

 

몇가지 버그가 있어서 버그를 수정해본다.

 

죽으면 이동못하게 막기

void AABCharacterNonPlayer::SetDead()
{
	Super::SetDead();

	AABAIController* ABAIController = Cast<AABAIController>(GetController());
	if (ABAIController)
	{
		ABAIController->StopAI();
	}

	...
}

적이 죽어도 움직여서 죽은경우 StopAI로 Behavior Tree 정지 시키기

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

	APlayerController* PlayerController = Cast<APlayerController>(GetController());
	if (PlayerController)
	{
		EnableInput(PlayerController);
	}

	SetCharacterControl(CurrentCharacterControlType);
}

void AABCharacterPlayer::SetDead()
{
	Super::SetDead();

	APlayerController* PlayerController = Cast<APlayerController>(GetController());
	if (PlayerController)
	{
		DisableInput(PlayerController);
	}
}

Character는 이동을 못하게 막아버리기

대신 시작할땐 활성화 하도록 하기

 

이동속도 변화 적용

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

	Stat->OnHpZero.AddUObject(this, &AABCharacterBase::SetDead);
	Stat->OnStatChanged.AddUObject(this, &AABCharacterBase::ApplyStat);
}

void AABCharacterBase::ApplyStat(const FABCharacterStat& BaseStat, const FABCharacterStat& ModifierStat)
{
	float MovementSpeed = (BaseStat + ModifierStat).MovementSpeed;
	GetCharacterMovement()->MaxWalkSpeed = MovementSpeed;
}

이동속도가 적용이 되지 않았다.

스탯값이 변경된다면 변경된 것을 적용할 것.

 

다른 아이템도 만들기

포션과 스크롤을 구현하지 않았다.

class ARENABATTLE_API UABScrollItemData : public UABItemData
{
	GENERATED_BODY()
	
public:
	UABScrollItemData();

	FPrimaryAssetId GetPrimaryAssetId() const override
	{
		return FPrimaryAssetId("ABItemData", GetFName());
	}

public:
	UPROPERTY(EditAnywhere, Category = Stat)
	FABCharacterStat BaseStat;
};


//////


class ARENABATTLE_API UABPotionItemData : public UABItemData
{
	GENERATED_BODY()
	
public:
	UABPotionItemData();

	FPrimaryAssetId GetPrimaryAssetId() const override
	{
		return FPrimaryAssetId("ABItemData", GetFName());
	}

public:
	UPROPERTY(EditAnywhere, Category = Hp)
	float HealAmount;
};

스크롤은 기본스텟 증가를 하고 포션은 힐을 하게 될것이다.

 

해당 스크립트를 토대로 블루프린트를 제작한다.

제작한 블루프린트를 각각 스탯을 변경하고 모두 다시 로드한다.

 

CharacterStatComponent에 baseState을 추가하는 메소드와 힐 메소드가 없어서 하나 만들어준다.

FORCEINLINE void AddBaseStat(const FABCharacterStat& InAddBaseStat) { BaseStat = BaseStat + InAddBaseStat; OnStatChanged.Broadcast(GetBaseStat(), GetModifierStat()); }
FORCEINLINE void HealHp(float InHealAmount) { CurrentHp = FMath::Clamp(CurrentHp + InHealAmount, 0, GetTotalStat().MaxHp); OnHpChanged.Broadcast(CurrentHp); }

이후 CharacterBase에 정의된 내용에 추가한다.

void AABCharacterBase::DrinkPotion(UABItemData* InItemData)
{
	UABPotionItemData* PotionItemData = Cast<UABPotionItemData>(InItemData);
	if (PotionItemData)
	{
		Stat->HealHp(PotionItemData->HealAmount);
	}
}

void AABCharacterBase::ReadScroll(UABItemData* InItemData)
{
	UABScrollItemData* ScrollItemData = Cast<UABScrollItemData>(InItemData);
	if (ScrollItemData)
	{
		Stat->AddBaseStat(ScrollItemData->BaseStat);
	}
}

 

제작한 아이템들에게 데이터를 총괄 변경을 원한다면 모두 선택하고 Bulk Edit via property matrix 를 선택하면 일괄 조절이 가능하다.

 

 

우측에 있는 핀 버튼을 누르면 해당 값을 수정하기 편하게 도와준다.

 

 

 

 

게임 모드

멀티플레이를 포함해 게임에서 유일하게 존재하는 게임의 심판 오브젝트

최상단에서 게임의 진행을 관리하며, 게임 판정에 관련된 중요한 행동을 주관하는데 적합함.

다양한 게임 규칙을 적용할 수 있도록 해김 기능과 분리해 설계하는 것이 바람직함.

게임의 상태와 플레이어의 상태를 별도로 저장할 수 있는 프레임웍을 제공함.

 

다른 스크립트에서 게임모드에 관해 데이터를 가져올 예정이라서 인터페이스 구현한다.

class ARENABATTLE_API IABGameInterface
{
	GENERATED_BODY()

	// Add interface functions to this class. This is the class that will be inherited to implement this interface.
public:
	virtual void OnPlayerScoreChanged(int32 NewPlayerScore) = 0;
	virtual void OnPlayerDead() = 0;
	virtual bool IsGameCleared() = 0;
};

 

 

게임모드는 인터페이스를 상속받아 제작한다.

class ARENABATTLE_API AABGameMode : public AGameModeBase, public IABGameInterface
{
	GENERATED_BODY()
	
public:
	AABGameMode();

	virtual void OnPlayerScoreChanged(int32 NewPlayerScore) override;
	virtual void OnPlayerDead() override;
	virtual bool IsGameCleared() override;

	UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = Game)
	int32 ClearScore;

	UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = Game)
	int32 CurrentScore;

	UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = Game)
	uint8 bIsCleared : 1;
	
};

클리어 목표 점수와 현재 점수, 클리어 유무를 변수로 둔다.

AABGameMode::AABGameMode()
{
	static ConstructorHelpers::FClassFinder<APawn> DefaultPawnClassRef(TEXT("/Script/Engine.Blueprint'/Game/ArenaBattle/Blueprint/BP_ABCharacterPlayer.BP_ABCharacterPlayer_C'"));
	if (DefaultPawnClassRef.Class)
	{
		DefaultPawnClass = DefaultPawnClassRef.Class;
	}

	static ConstructorHelpers::FClassFinder<APlayerController> PlayerControllerClassRef(TEXT("/Script/ArenaBattle.ABPlayerController"));
	if (PlayerControllerClassRef.Class)
	{
		PlayerControllerClass = PlayerControllerClassRef.Class;
	}

	ClearScore = 3;
	CurrentScore = 0;
	bIsCleared = false;
}

void AABGameMode::OnPlayerScoreChanged(int32 NewPlayerScore)
{
	CurrentScore = NewPlayerScore;

	AABPlayerController* ABPlayerController = Cast<AABPlayerController>(GetWorld()->GetFirstPlayerController());
	if (ABPlayerController)
	{
		ABPlayerController->GameScoreChanged(CurrentScore);
	}

	if (CurrentScore >= ClearScore)
	{
		bIsCleared = true;

		if (ABPlayerController)
		{
			ABPlayerController->GameClear();
		}
	}
}

void AABGameMode::OnPlayerDead()
{
	AABPlayerController* ABPlayerController = Cast<AABPlayerController>(GetWorld()->GetFirstPlayerController());
	if (ABPlayerController)
	{
		ABPlayerController->GameOver();
	}
}

bool AABGameMode::IsGameCleared()
{
	return bIsCleared;
}

 

점수 작동 방식은 몹을 잡을때 마다 점수가 오르게 하려고 하기 때문에 Gimmick에서 몹이 죽으면 이를 참조해서 점수가 오르도록 제작한다.

void AABStageGimmick::OnOpponentDestroyed(AActor* DestroyedActor)
{
	IABGameInterface* ABGameMode = Cast<IABGameInterface>(GetWorld()->GetAuthGameMode());
	if (ABGameMode)
	{
		ABGameMode->OnPlayerScoreChanged(CurrentStageNum);
		if (ABGameMode->IsGameCleared())
		{
			return;
		}
	}

	SetState(EStageState::REWARD);
}

 

같은 방식으로 플레이어가 죽으면 게임오버를 호출해야 함으로 로직을 수정한다.

void AABCharacterPlayer::SetDead()
{
	Super::SetDead();

	APlayerController* PlayerController = Cast<APlayerController>(GetController());
	if (PlayerController)
	{
		DisableInput(PlayerController);

		IABGameInterface* ABGameMode = Cast<IABGameInterface>(GetWorld()->GetAuthGameMode());
		if (ABGameMode)
		{
			ABGameMode->OnPlayerDead();
		}
	}
}

 

게임상태에 따라 Widget을 변경할 예정이라 PlayerController도 수정한다.

	UFUNCTION(BlueprintImplementableEvent, Category = Game, Meta = (DisplayName = "OnScoreChangedCpp"))
	void K2_OnScoreChanged(int32 NewScore);
	UFUNCTION(BlueprintImplementableEvent, Category = Game, Meta = (DisplayName = "OnGameClearCpp"))
	void K2_OnGameClear();
	UFUNCTION(BlueprintImplementableEvent, Category = Game, Meta = (DisplayName = "OnGameOverCpp"))
	void K2_OnGameOver();
	UFUNCTION(BlueprintImplementableEvent, Category = Game, Meta = (DisplayName = "OnGameRetryCountCpp"))
	void K2_OnGameRetryCount(int32 NewRetryCount);

	void GameScoreChanged(int32 NewScore);
	void GameClear();
	void GameOver();

UFunction은 Blueprint에서도 수정이 가능하게 해주는 메크로이다.

BlueprintImplementableEvent는 블루프린트에서 이벤트처럼 쓰기 위한 키워드이다.

앞에 붙은 K2는 블루프린트의 전신인 Kismet이라고 하는 기능을 붙이는 접두사인데 큰 상관은 없다.

다만 아래에 쓰인 Cpp용 메소드와 이름이 겹치면서 같은 기능을 하기 때문에 분리를 시키는게 더 보기 좋을 뿐이다.

 

void AABPlayerController::GameScoreChanged(int32 NewScore)
{
	K2_OnScoreChanged(NewScore);
}

void AABPlayerController::GameClear()
{
	K2_OnGameClear();
}

void AABPlayerController::GameOver()
{
	K2_OnGameOver();

	if (!UGameplayStatics::SaveGameToSlot(SaveGameInstance, TEXT("Player0"), 0))
	{
		UE_LOG(LogABPlayerController, Error, TEXT("Save Game Error!"));
	}

	K2_OnGameRetryCount(SaveGameInstance->RetryCount);
}

블루프린트 이벤트처럼 호출하기 위해서 다음과 같이 코드가 작성되었다.

 

GameMode를 변경했으니 월드 셋팅에도 변경해준다.

 

PlayerController에서 사용한 블루프린트이다

 

 

 

 

게임을 저장하는 방법을 제작한다.

SaveGame이라는 언리얼 오브젝트를 선택해서 상속받아 제작한다.

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

	UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = Game)
	int32 RetryCount;
};

/////

UABSaveGame::UABSaveGame()
{
	RetryCount = 0;
}

다시 도전하는 숫자만 저장하도록 제작했다.

 

위 값은 Controller에서 변수로 관리한다.

...
// Save Game Section
protected:
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = SaveGame)
	TObjectPtr<class UABSaveGame> SaveGameInstance;
};
void AABPlayerController::BeginPlay()
{
	Super::BeginPlay();

	FInputModeGameOnly GameOnlyInputMode;
	SetInputMode(GameOnlyInputMode);

	SaveGameInstance = Cast<UABSaveGame>(UGameplayStatics::LoadGameFromSlot(TEXT("Player0"), 0));
	if (!SaveGameInstance)
	{
		SaveGameInstance = NewObject<UABSaveGame>();
		SaveGameInstance->RetryCount = 0;
	}
	SaveGameInstance->RetryCount++;

	K2_OnGameRetryCount(SaveGameInstance->RetryCount);
}

0번은 플레이어의 ID이다.

이렇게 게임을 로드할 수 있다.

void AABPlayerController::GameOver()
{
	K2_OnGameOver();

	if (!UGameplayStatics::SaveGameToSlot(SaveGameInstance, TEXT("Player0"), 0))
	{
		UE_LOG(LogABPlayerController, Error, TEXT("Save Game Error!"));
	}

	K2_OnGameRetryCount(SaveGameInstance->RetryCount);
}

게임이 끝난다면 게임을 저장한다.

 

해당 파일은 프로젝트에서 Saved 파일안에 SaveGames파일 안에 주어진 이름으로 저장이 된다.

 

게임을 빌드해서 실행파일로 만든다.

Shipping을 하면 가장 빠르고 용량이 작은 최종 결과물을 만들 수 있다.

따라서 개발 모드로 충분히 진행하고(Development) 다음에 Shipping을 하는걸 권장한다.

 

에셋은 괜찮지만 아이템에 대한 정보는 Cook 정보에 빠지면 로드를 못함으로 지정해주어야 한다.

프로젝트 Packaging에 있다.

패키지 프로젝트를 누르면 실행파일이 생성된다.

 

 

 

 

 

반응형