[TIL_250213_3] 게임 루프 설계를 통한 게임 흐름 제어
2025.02.13 - [Dev./UE 언리얼 엔진] - [TIL_250213_1] 머티리얼 시스템 이해 (기초) 2 #그래픽스
[TIL_250213_1] 머티리얼 시스템 이해 (기초) 2 #그래픽스
📝 회고머티리얼 시스템은 언리얼 엔진에서 그래픽 표현을 담당하는 중요한 요소이다. 오늘은 기초 머티리얼 노드부터 동적 머티리얼을 생성하고 실시간으로 변화시키는 방법까지 학습했다.
raindrovvv.tistory.com
2025.02.13 - [Dev./UE 언리얼 엔진] - [TIL_250213_2] 캐릭터 체력 시스템 구현
[TIL_250213_2] 캐릭터 체력 시스템 구현
📒학습 내용🛡️ 캐릭터 체력 시스템1. 핵심 구조캐릭터 클래스 내에 Health (현재 체력)와 MaxHealth (최대 체력)를 선언한다.PlayerState 대신 Character 클래스 내부에서 체력을 관리한다.변수 및 함
raindrovvv.tistory.com
🤔회고
하나의 미니 게임 웨이브 설계, 구현하면서 새로운 개념들을 배우는 것에 어려움이 있었다...
구현해야 할 것들이 매우 많은 것도 걸림돌이었지만, 각각의 기능들을 연동하는 과정이 꽤나 헷갈렸다. 더군다나 독한 감기에 걸려 컨디션 저하로 속도가 나지 않아 도전 과제까지 하지 못한 것이 아쉽다. 나머지 기능도 차근차근 구현해봐야겠다.
UI 위젯 설계와 실시간 데이터 연동하는 방법에 대해 내일 정리해봐야지...
🗺️마인드맵
📒학습 내용
1. 들어가기 전에: 기본 개념 및 사전 준비
GameMode와 GameState가 서로 긴밀하게 연동되어 작동한다. 기존 코드에서는 기본 기능만 제공하는 GameStateBase를 사용하였으나, 고급 기능이 필요한 경우 완전한 기능을 제공하는 GameState 클래스로 변경하는 것이 필요하다. 이를 통해 GameMode와의 호환성이 개선되며 다양한 게임 기능을 구현할 수 있다.
중요 포인트: GameState에 있던 코드를 모두 새로운 GameState 클래스로 옮기고, 관련 GameMode.cpp 및 CoinItem 코드도 일부 수정한다..
2. GameState를 이용한 게임 루프 구현
핵심 요약: 게임루프 구현
- GameState를 활용하여 게임의 전반적인 상태를 관리한다.
- 아이템 스폰 및 점수 시스템을 통해 게임의 진행 상황을 제어한다.
- 시간 제한과 레벨 전환 로직을 구현하여 자연스러운 게임 흐름을 유지한다.
GameMode와 GameState의 역할
클래스 | 역할 | 특징 |
GameMode | 게임 규칙을 정의하고 관리 | 멀티플레이에서는 서버에서만 실행됨 |
GameState | 게임 전반의 전역 상태를 관리 | 서버-클라이언트가 공유하는 정보 저장 |
SpawnVolume 클래스 수정
SpawnVolume 클래스에서는 스폰된 아이템의 정보를 반환하도록 수정하여 GameState에서 아이템 수를 추적할 수 있도록 한다.
기존 방식
- SpawnRandomItem() 함수가 void를 반환한다 한다.
변경 방식
- SpawnRandomItem() 함수가 스폰된 AActor 포인터를 반환하도록 수정하여, 스폰된 아이템이 코인인지 판별할 수 있도록 한다 한다.
// SpawnVolume.h
UFUNCTION(BlueprintCallable, Category = "Spawn")
AActor* SpawnRandomItem(); // 리턴 형식을 AActor*로 변경한다
// SpawnVolume.cpp
AActor* ASpawnVolume::SpawnRandomItem() {
if (FItemSpawnRow* SelectedRow = GetRandomItem()) {
if (UClass* ActualClass = SelectedRow->ItemClass.Get()) {
return SpawnItem(ActualClass);
}
}
return nullptr;
}
AActor* ASpawnVolume::SpawnItem(TSubclassOf<AActor> ItemClass) {
if (!ItemClass) return nullptr;
AActor* SpawnedActor = GetWorld()->SpawnActor<AActor>(
ItemClass,
GetRandomPointInVolume(),
FRotator::ZeroRotator
);
return SpawnedActor;
}
실무 팁: 함수의 리턴 타입을 변경하여 반환된 객체의 타입을 판별함으로써 코인 아이템을 정확히 추적할 수 있다.
GameState 기반의 게임 루프 구현
- 초기 레벨의 게임 규칙 설정
- 40개의 아이템을 스폰하고, 30초 내에 모든 코인을 획득하면 다음 레벨로 이동하도록 한다.
- 타임오버가 되어도 다음 레벨로 전환되도록 한다.
- GameState를 활용하여 아이템 개수 및 점수를 추적하며 게임 루프를 제어한다.
- 지뢰의 폭발로 체력이 0이 되면 게임 오버 처리한다.
- 게임 종료 후 재시작 버튼을 통해 게임을 다시 시작할 수 있도록 한다.
GameState에서 관리하는 변수 (MyGameState.h)
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameState.h"
#include "MyGameState.generated.h"
UCLASS()
class START_API AMyGameState : public AGameState
{
GENERATED_BODY()
public:
AMyGameState();
virtual void BeginPlay() override;
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "Score")
int32 Score;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Coin")
int32 SpawnedCoinCount;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Coin")
int32 CollectCoinCount;
FTimerHandle LevelTimerHandle;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Level")
float LevelDuration; // 레벨 제한 시간 (30초)
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Level")
int32 CurrentLevelIndex; // 현재 진행 중인 레벨 인덱스
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Level")
int32 MaxLevels; // 전체 레벨 개수를 정의한다
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Level")
TArray<FName> LevelMapNames;
// 점수를 가져오는 함수
UFUNCTION(BlueprintPure, Category = "Score")
int32 GetScore() const;
UFUNCTION(BlueprintCallable, Category = "Score")
void AddScore(int32 Amount);
// 게임 오버 함수
UFUNCTION(BlueprintCallable, Category = "Level")
void OnGameOver();
// 초기 레벨 설정 및 게임 루프 관련 함수
void StartLevel();
void OnLevelTimeUp();
void OnCoinCollected();
void EndLevel();
};
레벨 시작 및 아이템 스폰 (MyGameState.cpp)
#include "MyGameState.h"
#include "Kismet/GameplayStatics.h"
#include "SpawnVolume.h"
#include "CoinItem.h"
AMyGameState::AMyGameState() {
Score = 0;
SpawnedCoinCount = 0;
CollectCoinCount = 0;
LevelDuration = 30.f;
CurrentLevelIndex = 0;
MaxLevels = 0;
}
void AMyGameState::BeginPlay() {
Super::BeginPlay();
StartLevel();
}
int32 AMyGameState::GetScore() const {
return Score;
}
void AMyGameState::AddScore(int32 Amount) {
Score += Amount;
GEngine->AddOnScreenDebugMessage(
-1, 2.0f, FColor::Green, FString::Printf(TEXT("Score : %d"), Score)
);
}
void AMyGameState::StartLevel() {
SpawnedCoinCount = 0;
CollectCoinCount = 0;
// 현재 맵의 SpawnVolume을 찾아 40개의 아이템을 스폰한다
TArray<AActor*> FoundVolumes;
UGameplayStatics::GetAllActorsOfClass(GetWorld(), ASpawnVolume::StaticClass(), FoundVolumes);
const int32 ItemToSpawn = 40;
for (int32 i = 0; i < ItemToSpawn; i++) {
if (FoundVolumes.Num() > 0) {
ASpawnVolume* SpawnVolume = Cast<ASpawnVolume>(FoundVolumes[0]);
if (SpawnVolume) {
AActor* SpawnedActor = SpawnVolume->SpawnRandomItem();
if (SpawnedActor && SpawnedActor->IsA(ACoinItem::StaticClass())) {
SpawnedCoinCount++;
}
}
}
}
// 30초 후 OnLevelTimeUp() 함수를 호출한다
GetWorldTimerManager().SetTimer(
LevelTimerHandle,
this,
&AMyGameState::OnLevelTimeUp,
LevelDuration,
false
);
GEngine->AddOnScreenDebugMessage(
-1, 2.0f, FColor::Yellow,
FString::Printf(TEXT("Level %d Start, Spawned %d coin"), CurrentLevelIndex + 1, SpawnedCoinCount)
);
}
void AMyGameState::OnLevelTimeUp() {
// 시간 초과 처리 후 레벨 종료 함수를 호출한다
EndLevel();
}
void AMyGameState::EndLevel() {
GetWorldTimerManager().ClearTimer(LevelTimerHandle);
CurrentLevelIndex++;
if (CurrentLevelIndex >= MaxLevels) {
OnGameOver();
return;
}
if (LevelMapNames.IsValidIndex(CurrentLevelIndex)) {
UGameplayStatics::OpenLevel(GetWorld(), LevelMapNames[CurrentLevelIndex]);
} else {
OnGameOver();
}
}
void AMyGameState::OnCoinCollected() {
CollectCoinCount++;
GEngine->AddOnScreenDebugMessage(
-1, 2.0f, FColor::Green,
FString::Printf(TEXT("Coin collected: %d / %d"), CollectCoinCount, SpawnedCoinCount)
);
if (SpawnedCoinCount > 0 && CollectCoinCount >= SpawnedCoinCount) {
EndLevel();
}
}
코인 획득 시 처리 (CoinItem.cpp)
#include "CoinItem.h"
#include "Engine/World.h"
#include "MyGameState.h"
ACoinItem::ACoinItem() {
PointValue = 0;
ItemType = "DefaultCoinItem";
}
void ACoinItem::ActivateItem(AActor* Activator) {
if (Activator && Activator->ActorHasTag("Player")) {
if (UWorld* World = GetWorld()) {
if (AMyGameState* GameState = World->GetGameState<AMyGameState>()) {
GameState->AddScore(PointValue);
GameState->OnCoinCollected();
}
}
DestroyItem();
}
}
3. Game Instance를 활용한 데이터 유지
언리얼 엔진에서는 Game Instance가 게임 실행 중 단 하나만 존재하며, 맵 변경 시에도 데이터가 유지되는 특징을 갖는다. 이를 통해 레벨 변경 시 초기화되는 문제를 해결할 수 있다.
Game Instance란
- 게임 실행 중 단 하나만 존재한다
- 맵이 변경되어도 사라지지 않는다
- 전역 데이터를 저장하는 데 유용하다
💡중요 포인트: GameState에서 점수를 관리하면 레벨 변경 시 초기화되지만, Game Instance를 활용하면 모든 레벨에서 데이터를 유지할 수 있다.
Game Instance 생성 및 변수 선언 (MyGameInstance.h / .cpp)
UCLASS()
class START_API UMyGameInstance : public UGameInstance
{
GENERATED_BODY()
public:
UMyGameInstance();
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category="GameData")
int32 TotalScore;
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category="GameData")
int32 CurrentLevelIndex;
// 점수 총합을 더하는 함수
UFUNCTION(BlueprintCallable, Category="GameData")
void AddToScore(int32 Amount);
};
#include "MyGameInstance.h"
UMyGameInstance::UMyGameInstance() {
TotalScore = 0;
CurrentLevelIndex = 0;
}
void UMyGameInstance::AddToScore(int32 Amount) {
TotalScore += Amount;
UE_LOG(LogTemp, Warning, TEXT("Total score : %d"), TotalScore);
}
Game Instance와 GameState 연동
GameState의 AddScore() 함수에서 Game Instance의 점수를 업데이트하도록 코드를 수정한다.
void AMyGameState::AddScore(int32 Amount) {
if (UGameInstance* GameInstance = GetGameInstance()) {
UMyGameInstance* MyGameInstance = Cast<UMyGameInstance>(GameInstance);
if (MyGameInstance) {
MyGameInstance->AddToScore(Amount);
}
}
GEngine->AddOnScreenDebugMessage(
-1, 2.0f, FColor::Green, FString::Printf(TEXT("Score : %d"), Score)
);
}
또한, StartLevel() 함수에서 Game Instance의 현재 레벨 인덱스를 불러오도록 한다.
void AMyGameState::StartLevel() {
if (UGameInstance* GameInstance = GetGameInstance()) {
UMyGameInstance* MyGameInstance = Cast<UMyGameInstance>(GameInstance);
if (MyGameInstance) {
CurrentLevelIndex = MyGameInstance->CurrentLevelIndex;
}
}
// 아이템 스폰 로직을 진행한다
SpawnedCoinCount = 0;
CollectCoinCount = 0;
TArray<AActor*> FoundVolumes;
UGameplayStatics::GetAllActorsOfClass(GetWorld(), ASpawnVolume::StaticClass(), FoundVolumes);
const int32 ItemToSpawn = 40;
for (int32 i = 0; i < ItemToSpawn; i++) {
if (FoundVolumes.Num() > 0) {
ASpawnVolume* SpawnVolume = Cast<ASpawnVolume>(FoundVolumes[0]);
if (SpawnVolume) {
AActor* SpawnedActor = SpawnVolume->SpawnRandomItem();
if (SpawnedActor && SpawnedActor->IsA(ACoinItem::StaticClass())) {
SpawnedCoinCount++;
}
}
}
}
GetWorldTimerManager().SetTimer(
LevelTimerHandle,
this,
&AMyGameState::OnLevelTimeUp,
LevelDuration,
false
);
GEngine->AddOnScreenDebugMessage(
-1, 2.0f, FColor::Yellow,
FString::Printf(TEXT("Level %d Start, Spawned %d coin"), CurrentLevelIndex + 1, SpawnedCoinCount)
);
}
또한, EndLevel() 함수에서는 Game Instance의 현재 레벨 인덱스를 업데이트한다.
void AMyGameState::EndLevel() {
if (UGameInstance* GameInstance = GetGameInstance()) {
UMyGameInstance* MyGameInstance = Cast<UMyGameInstance>(GameInstance);
if (MyGameInstance) {
MyGameInstance->CurrentLevelIndex = CurrentLevelIndex;
}
}
GetWorldTimerManager().ClearTimer(LevelTimerHandle);
CurrentLevelIndex++;
if (CurrentLevelIndex >= MaxLevels) {
OnGameOver();
return;
}
if (LevelMapNames.IsValidIndex(CurrentLevelIndex)) {
UGameplayStatics::OpenLevel(GetWorld(), LevelMapNames[CurrentLevelIndex]);
} else {
OnGameOver();
}
}
아이템 개수 수정 방법
아이템 개수를 수정하는 방법은 세 가지로 나누어 적용할 수 있다 한다.
- GameState에서 직접 수정
- StartLevel() 함수 내의 const int32 ItemToSpawn = 40; 값을 변경하여 아이템 개수를 조정한다 한다.
- 블루프린트에서 수정
- GameState의 블루프린트 버전을 열어, StartLevel() 함수에서 아이템 개수를 인수로 받도록 변경한 후 블루프린트 노드에서 값을 수정할 수 있도록 설정한다 한다.
- 레벨마다 다른 아이템 개수 설정
- 레벨별 데이터 테이블이나 배열을 사용하여 각 레벨마다 다른 아이템 개수를 설정한다 한다.
// MyGameState.h 파일에 레벨별 아이템 개수 배열을 추가한다
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Level")
TArray<int32> ItemsPerLevel; // 레벨별 아이템 개수를 정의한다
void AMyGameState::StartLevel() {
SpawnedCoinCount = 0;
CollectCoinCount = 0;
int32 ItemToSpawn = 40; // 기본값을 설정한다
if (ItemsPerLevel.IsValidIndex(CurrentLevelIndex)) {
ItemToSpawn = ItemsPerLevel[CurrentLevelIndex]; // 해당 레벨에 맞는 아이템 개수를 적용한다
}
// 나머지 아이템 스폰 로직은 동일하게 진행한다
}
✨실무 팁: 레벨마다 다른 난이도와 게임 밸런스를 위해 아이템 개수를 유연하게 조정할 수 있도록 설계한다.