인터페이스는 각 클래스에서 반드시 사용하는 함수를 지니고 있는 클래스이다.
어떠한 함수를 정의를 하여 반드시 사용하게 하지만, 그 함수의 기능은 각 클래스에서 구현한다.
상속과는 다르게 모든 기능을 그대로 물려받는게 아니라, 이 함수를 쓰기로 약속하는 것이다.

언리얼에서 기본적으로 제공하는 Unreal Interface 를 통해 ItemInterface 라는 cpp 클래스를 생성해준다.
#pragma once
#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "ItemInterface.generated.h"
UINTERFACE(MinimalAPI)
class UItemInterface : public UInterface
{
GENERATED_BODY()
};
class HW0607_API IItemInterface
{
GENERATED_BODY()
public:
UFUNCTION()
virtual void OnItemBeginOverlap(
UPrimitiveComponent* OverlappedComp,
AActor* OtherActor,
UPrimitiveComponent* OtherComp,
int32 OtherBodyIdx,
bool bFromSweep,
const FHitResult& SweepResult
) = 0;
UFUNCTION()
virtual void OnItemEndOverlap(
UPrimitiveComponent* OverlappedComp,
AActor* OtherActor,
UPrimitiveComponent* OtherComp,
int32 OtherBodyIdx
) = 0;
UFUNCTION()
virtual void ActivateItem(AActor* Activator) = 0;
UFUNCTION()
virtual FName GetItemType() const = 0;
};
그리고 아이템들이 사용할 함수들을 정의해놓는다.
먼저 OnItemBeginOverlap 함수는 어떠한 액터가 아이템에 충돌했을 때를 감지하는 함수이고, 반대로 OnItemEndOverlap 은 액터가 그 충돌 범위를 벗어날 때 감지하는 함수이다.
그리고 이 함수들의 매개변수들은 위에 세 가지만 알면 된다고 했기에 이것만 설명하기로 했다.
각 매개변수들은 충돌당한 자신의 컴포넌트, 충돌한 액터, 가장 먼저 충돌한 컴포넌트를 뜻한다.
그 다음은 ActivateItem 함수이다.
아이템이 충돌했을 때 어떤 동작을 할 지를 결정해주는 함수이다.
마지막으로 GetItemType 함수이다.
아이템들의 이름을 언리얼에서 기본 제공하는 FName 타입을 이용하여 가져올 수 있도록 했다.
또한 이 함수들이 엔진에서 감지할 수 있도록 UFUNCTION() 을 붙여 리플렉션 시스템에 등록한다.
인터페이스에서 한 번 등록하면 이후에 함수 작성할 때에는 다시 붙여주지 않아도 된다.

이제 인터페이스의 함수를 사용할 아이템을 만들기 위해서, 일단 아이템들의 기반이 될 액터 BaseItem 을 생성해준다.
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "ItemInterface.h"
#include "BaseItem.generated.h"
먼저 헤더 파일에서 인터페이스 헤더를 include 해준다.
BaseItem.generated.h 보다 아래에 오지 않도록 조심한다.
class USphereComponent;
또한 스피어 컴포넌트를 사용하기 위해 위 쪽에 전방선언을 하나 해주어야한다.
UCLASS()
class HW0607_API ABaseItem : public AActor , public IItemInterface
클래스에선 Actor 뿐만 아니라 ItemInterface 도 가져올 수 있게 해준다.
protected:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Categort = "Itme");
FName ItemType;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly , Categort = "Itme|Component");
USceneComponent* SceneRoot;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Categort = "Itme|Component");
USphereComponent* Collision;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Categort = "Itme|Component");
UStaticMeshComponent* StaticMesh;
virtual void OnItemBeginOverlap(
UPrimitiveComponent* OverlappedComp,
AActor* OtherActor,
UPrimitiveComponent* OtherComp,
int32 OtherBodyIdx,
bool bFromSweep,
const FHitResult& SweepResult
) override;
virtual void OnItemEndOverlap(
UPrimitiveComponent* OverlappedComp,
AActor* OtherActor,
UPrimitiveComponent* OtherComp,
int32 OtherBodyIdx
) override;
virtual void ActivateItem(AActor* Activator) override;
virtual FName GetItemType() const override;
virtual void DestroyItem();
};
그리고 우선 함수를 오버라이딩 하기 전에, 아이템의 이름을 변경할 수 있도록 변수를 만든 후 리플렉션 시스템에 등록해주고 베이스 아이템의 컴포넌트들을 선언해주었다.
그런 다음 아까 인터페이스에 정의한 함수들을 오버라이딩 해주었다.
거기에 추가적으로 아이템이 사라지는 함수를 만들어두었다. 이는 아이템의 특징이기 때문에 인터페이스가 아닌 베이스 아이템 쪽에서 정의했다.
#include "BaseItem.h"
#include "Components/SphereComponent.h"
cpp 파일에서는 우선 스피어 컴포넌트를 사용하기 위해 헤더를 추가해주고,
ABaseItem::ABaseItem()
{
PrimaryActorTick.bCanEverTick = false;
SceneRoot = CreateDefaultSubobject<USceneComponent>(TEXT("SceneRoot"));
SetRootComponent(SceneRoot);
Collision = CreateDefaultSubobject<USphereComponent>(TEXT("Collision"));
Collision->SetupAttachment(SceneRoot);
StaticMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("StaticMesh"));
StaticMesh->SetupAttachment(SceneRoot);
Collision->OnComponentBeginOverlap.AddDynamic(this, &ABaseItem::OnItemBeginOverlap);
Collision->OnComponentEndOverlap.AddDynamic(this, &ABaseItem::OnItemEndOverlap);
}
생성자에서 컴포넌트들을 설정해준 뒤, 콜리전의 충돌 정보를 내가 만든 함수에 저장이 되도록 한다.
void ABaseItem::OnItemBeginOverlap(
UPrimitiveComponent* OverlappedComp,
AActor* OtherActor,
UPrimitiveComponent* OtherComp,
int32 OtherBodyIdx,
bool bFromSweep,
const FHitResult& SweepResult
)
{
if (OtherActor && OtherActor->ActorHasTag("Player"))
{
ActivateItem(OtherActor);
}
}
void ABaseItem::OnItemEndOverlap(
UPrimitiveComponent* OverlappedComp,
AActor* OtherActor,
UPrimitiveComponent* OtherComp,
int32 OtherBodyIdx
)
{
}
void ABaseItem::ActivateItem(AActor* Activator)
{
}
FName ABaseItem::GetItemType() const
{
return ItemType;
}
void ABaseItem::DestroyItem()
{
Destroy();
}
그리고 함수 구현부 틀을 잡아주고 필요한 부분만 하나씩 구현해준다.
OnItemBeginOverlap 함수에서는 OtherActor 가 null 인지 체크하고, 그게 Player 라는 태그를 가진 액터인지 확인하고 맞다면 OtherActor 를 매개변수로 가진 ActivateItem 함수를 호출한다.
그 아래 2개의 함수는 우선 두고, GetItemType 함수에서는 ItemType 을 반환해주고,
DestroyItem 함수에서는 해당 액터를 파괴시키는 Destroy 함수를 호출시켜준다.

부모 클래스를 베이스 아이템으로 선택하고, CoinItem 을 생성해준다.
#pragma once
#include "CoreMinimal.h"
#include "BaseItem.h"
#include "CoinItem.generated.h"
UCLASS()
class HW0607_API ACoinItem : public ABaseItem
{
GENERATED_BODY()
public:
ACoinItem();
virtual void ActivateItem(AActor* Activator) override;
protected:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Coin")
int32 CoinValue;
};
코인 아이템에 충돌이 감지됐을 때를 위한 ActivateItem() 함수와 코인의 가치을 결정해주는 CoinValue 변수를 선언해주었다.
또한 코인의 가치는 에디터에서도 수정할 수 있도록 EditAnywhere 로 설정했다.
#include "CoinItem.h"
ACoinItem::ACoinItem()
{
CoinValue = 10;
ItemType = "Coin";
}
void ACoinItem::ActivateItem(AActor* Activator)
{
if (Activator && Activator->ActorHasTag("Player"))
{
//점수 획득 함수
DestroyItem();
}
}
소스 파일로 넘어와서 생성자에서 CoinValue 를 10으로 초기화해주고, ItemType 을 Coin 으로 정해준 뒤, ActivateItem 에 코인과 충돌했을 때의 동작을 구현했다.
충돌 시 점수를 획득하는 함수는 나중에 구현할 것이고, 이 후에 아이템이 사라지는 함수를 가져왔다.

이 후 블루프린트 클래스로 상속시켜줬다.

그리고 아까 Player 라는 태그를 감지한다고 했기 때문에, 캐릭터 블루프린트로 들어가 Details 탭에서 Actor > Tags 에서 태그를 생성해줘야한다.

cpp 클래스 액터를 SpawnVolume 이라는 이름으로 하나 생성해준다. 아이템들이 랜덤으로 스폰 될 범위를 나타내는 구역이다.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "SpawnVolume.generated.h"
class UBoxComponent;
UCLASS()
class HW08_API ASpawnVolume : public AActor
{
GENERATED_BODY()
public:
ASpawnVolume();
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Spawning")
USceneComponent* SceneRoot;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Spawning")
UBoxComponent* SpawningBox;
FVector GetRandomPointInVolume() const;
AActor* SpawnItem(TSubclassOf<AActor> ItemClass);
};
우선 헤더파일에 단순하게 루트 컴포넌트로 쓸 씬 컴포넌트와, 범위를 나타낼 박스 컴포넌트를 선언시켜준다.
언제나 그랬듯이, UBoxComponent 를 전방선언 해준다.
마지막으로 랜덤 위치를 가져올 FVector 를 리턴시켜줄 함수 하나와, 아이템을 액터의 하위클래스까지 감지하는 SpawnItem 함수를 하나 선언해주었다.
#include "SpawnVolume.h"
#include "Components/BoxComponent.h"
ASpawnVolume::ASpawnVolume()
{
PrimaryActorTick.bCanEverTick = false;
SceneRoot = CreateDefaultSubobject<USceneComponent>(TEXT("SceneRoot"));
SetRootComponent(SceneRoot);
SpawningBox = CreateDefaultSubobject<UBoxComponent>(TEXT("BoxComp"));
SpawningBox->SetupAttachment(SceneRoot);
}
소스파일로 넘어와 박스 컴포넌트 헤더를 include 해주고, 생성자에서 씬 컴포넌트를 루트 컴포넌트로 설정해주고, 박스 컴포넌트를 그 아래에 붙였다.
FVector ASpawnVolume::GetRandomPointInVolume() const
{
FVector BoxExtent = SpawningBox->GetScaledBoxExtent();
FVector BoxOrigin = SpawningBox->GetComponentLocation();
return BoxOrigin + FVector(
FMath::FRandRange(-BoxExtent.X, BoxExtent.X),
FMath::FRandRange(-BoxExtent.Y, BoxExtent.Y),
FMath::FRandRange(-BoxExtent.Z, BoxExtent.Z)
);
}
AActor* ASpawnVolume::SpawnItem(TSubclassOf<AActor> ItemClass)
{
if (!ItemClass) return nullptr;
AActor* SpawnedActor = GetWorld()->SpawnActor<AActor>(
ItemClass,
GetRandomPointInVolume(),
FRotator::ZeroRotator
);
return SpawnedActor;
}
이 후 함수들을 구현해준다.
GetRandomPointInVolume 함수에서 박스 콜리전의 중앙 기준에서 가장 바깥쪽 좌표 BoxExtent 와 중앙 좌표 BoxOrigin 을 선언해준다.
그리고 반환 값으로 중앙으로부터 랜덤한 거리를 주기 위해, BoxOrigin 에 FVector 에 FMath::FRandRange 를 통해 각 축마다 BoxExtent 의 값을 - ~ + 까지의 범위를 지정해 더해준다.

그렇게 만든 SpawnVolume 액터를 레벨의 크기에 맞게 잘 배치해주면 된다.

언리얼에는 구조체 클래스가 따로 없기 때문에 None 클래스를 ItemSpawnRow 라는 이름으로 하나 생성해준다.
#pragma once
#include "CoreMinimal.h"
#include "ItemSpawnRow.generated.h"
USTRUCT(BlueprintType)
struct FItemSpawnRow : public FTableRowBase
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadWrite)
FName ItemName;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TSubclassOf<AActor> ItemClass;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
float SpawnChance;
};
구조체 클래스는 소스 파일은 따로 구현하지 않고 헤더 파일에만 구현해주었다.
우선 클래스가 아닌 구조체이기 때문에 기본 구조를 없애고, 새로운 구조체를 생성해준다. 여기에 FTableRowBase 라는 것을 상속시켜주어야 우리가 만들 데이터 테이블 행에 이 구조체를 사용할 수가 있다.
이후 ItemName, ItemClass, SpawnChance 변수를 만들어준다. 각각 아이템의 이름, 클래스, 스폰 확률을 나타낸다. 전부 리플렉션 시스템에 등록해준다.

블루프린트들을 모아놓은 폴더에 우클릭하여 Miscellaneous > Data Table 을 선택해준다.

그리고 방금 내가 만든 구조체를 선택해준다.

Data Table 탭을 보면 아까 만든 변수들이 존재하는 것이 보인다.
그리고 Add 버튼을 눌러 행을 추가할 수 있는데,

밑에 있는 Row Editor 에서 값을 변경하여 넣어줄 수 있다.
아이템 이름은 CoinItem, 클래스는 BP_CoinItem, 스폰 확률은 30.0 으로 설정해주었다.
이제 이 데이터 테이블을 SpawnVolume 에서 불러와야한다.
#include "ItemSpawnRow.h"
우선 헤더 파일에 ItemSpawnRow.h 를 include 해주고,
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spawning")
UDataTable* ItemDataTable;
FItemSpawnRow* GetRandomItem() const;
AActor* SpawnRandomItem();
ItemDataTable 를 선언해주고 아이템 테이블에서 랜덤한 아이템을 가져오는 함수와, 직접 랜덤 스폰을 진행하는 함수를 만든다.
ItemDataTable = nullptr;
그러고나서 소스 파일로 넘어와 우선 ItemDataTable 을 nullptr 로 초기화 시켜준다.
FItemSpawnRow* ASpawnVolume::GetRandomItem() const
{
if (!ItemDataTable) return nullptr;
TArray<FItemSpawnRow*> AllRows;
static const FString ContextString(TEXT("ItemSpawnContext"));
ItemDataTable->GetAllRows(ContextString, AllRows);
if (AllRows.IsEmpty()) return nullptr;
float TotalChance = 0.0f;
for (const FItemSpawnRow* Row : AllRows)
{
if (Row)
{
TotalChance += Row->SpawnChance;
}
}
const float RandValue = FMath::FRandRange(0.0f, TotalChance);
float AccumulateChance = 0.0f;
for (FItemSpawnRow* Row : AllRows)
{
AccumulateChance += Row->SpawnChance;
if (RandValue <= AccumulateChance)
{
return Row;
}
}
return nullptr;
}
그런 다음 GetRandomItem 함수를 구현했다.
우선 ItemDataTable 이 존재하지 않으면 nullptr 을 반환한다.
그렇지 않다면 우선 FItemSpawnRow 타임으로 AllRows 라는 TArray 배열을 만들어주고, 디버깅 용도로 ContextString 을 하나 만들어준다.
그런 다음 ItemsDataTable 의 모든 행을 AllRows 에 담아준다.
만약 AllRows 가 비어있다면 nullptr 을 반환해준다.
그 다음으로는 아이템들의 총 확률을 넣을 TotalChance 를 만들어 0.0f 로 초기화 시켜주고 for 문을 통해 AllRows 를 각각 임시 변수 Row 에 담으며 이 Row가 존재하면 그 Row 의 SpawnChance 를 TotalChance 에 더해준다.
그렇게 모두 더하고 나면, RandValue 라는 변수에 0 ~ TotalChance 까지의 랜덤한 수를 저장해주고, 누적 확률인 AccumulateChance 를 0.0f 로 초기화 시켜놓는다.
for 문을 돌려 임시 변수 Row 에 AllRows 를 가져와 담아주고, RandValue 가 AccumulateChance 보다 작거나 같다면 그 Row 를 반환시켜준다.
만일을 대비해 마지막에 nullptr 도 반환시켜준다.
AActor* ASpawnVolume::SpawnRandomItem()
{
if (FItemSpawnRow* SelectedRow = GetRandomItem())
{
if (UClass* ActualClass = SelectedRow->ItemClass.Get())
{
return SpawnItem(ActualClass);
}
}
return nullptr;
}
SpawnRandomItem 함수는 랜덤으로 가져온 아이템을 SelectedRow 변수에 담고, 그 아이템의 클래스를 받아와 ActualCalss 에 다시 담는다.
그리고 그 아이템을 스폰하는 함수이다.

마지막으로 BP_SpawnVolume 에 들어가 Details 탭에서 Spawning > Item Data Table 로 가서 내가 만든 데이터 테이블인 ItemSpawnTable 을 넣어주면 된다.

우선 GameState 를 하나 생성해준다.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameState.h"
#include "MyGameState.generated.h"
UCLASS()
class HW08_API AMyGameState : public AGameState
{
GENERATED_BODY()
public:
AMyGameState();
UPROPERTY(VisibleAnyWhere, BlueprintReadWrite, Category = "Score")
int32 Score;
UFUNCTION(BlueprintPure, Category = "Score")
int32 GetScore() const;
UFUNCTION(BlueprintCallable, Category = "Score")
void AddScore(int32 Amount);
};
Score 변수와 이를 가져올 GetScore 함수, 점수를 추가하는 AddScore 함수를 선언해준다.
#include "MyGameState.h"
AMyGameState::AMyGameState()
{
Score = 0;
}
int32 AMyGameState::GetScore() const
{
return Score;
}
void AMyGameState::AddScore(int32 Amount)
{
Score += Amount;
}
소스 파일로 넘어와서, 생성자에서 Score 를 0 으로 초기화하고 GetScore 함수에서는 Score 를 반환, AddScore 함수에서는 매개변수로 들어오는 Amount 만큼 Score 에 누적 추가 시켜준다.


게임 모드를 하나 만들어주고 블루프린트로 상속시켜준다.

프로젝트 세팅에서 Default GameMode 를 BP_MyGameMode 로 바꾼 다음,
Default Pawn Class 를 BP_ThirdPersonClass 로 바꿔주고 (캐릭터를 만들어 둔게 버그가 걸려서 날아갔다...)
Game State Class 를 BP_MyGameState 로 바꿔준다.

우선 레벨을 3개 만들어 레벨이 올라 갈 수록 맵이 넓어져 점수를 얻기 힘들게 했다.
virtual void BeginPlay() override;
void OnCoinCollected();
void OnLevelTimeUp();
void StartLevel();
void EndLevel
UFUNCTION(BlueprintCallable, Category = "Level")
void OnGameOver();
FTimerHandle LevelTimerHandle;
UPROPERTY(VisibleAnyWhere, BlueprintReadWrite, Category = "Coin")
int32 SpawnedCoinCount;
UPROPERTY(VisibleAnyWhere, BlueprintReadWrite, Category = "Coin")
int32 CollectedCoinCount;
UPROPERTY(VisibleAnyWhere, BlueprintReadWrite, Category = "Level")
float LevelDuration;
UPROPERTY(VisibleAnyWhere, BlueprintReadWrite, Category = "Level")
int32 CurrentLevelIndex;
UPROPERTY(VisibleAnyWhere, BlueprintReadWrite, Category = "Level")
int32 MaxLevel;
UPROPERTY(EditAnyWhere, BlueprintReadWrite, Category = "Level")
TArray<FName> LevelMapNames;
그 다음으로는 GameState 헤더파일에 BeginPlay 함수를 넣고 게임을 시작하는 함수와 끝나는 함수인 StartLevel, EndLevel 을 추가한다. 게임 오버 함수도 추가했다.
그리고 제한 시간을 담당할 타이머 핸들러 LevelTimerHandle 과 제한 시간을 담은 LevelDuration 을 추가해주었다.
거기에 스폰된 코인과 획득한 코인의 양을 담은 변수도 선언한다.
마지막으로 이동할 레벨의 배열을 선언해주었다.
SpawnedCoinCount = 0;
CollectedCoinCount = 0;
LevelDuration = 30;
CurrentLevelIndex = 0;
생성자에서 모두 초기화 시켜주었다.
void AMyGameState::BeginPlay()
{
Super::BeginPlay();
StartLevel();
}
소스 파일로 넘어와서는 BeginPlay 함수를 구현하고 게임이 시작하면 StartLevel 이 호출되게 했다.
void AMyGameState::OnGameOver()
{
}
OnGameOVer 함수에 게임 오버를 구현할 예정이다. 아직은 구현하지 않는다.
void AMyGameState::OnLevelTimeUp()
{
EndLevel();
}
만약 타이머가 초과됐다면 OnLevelTimeUp 함수에서 EndLevel 을 호출한다.
void AMyGameState::OnCoinCollected()
{
CollectedCoinCount++;
if (SpawnedCoinCount > 0 && CollectedCoinCount >= SpawnedCoinCount)
{
EndLevel();
}
}
코인을 획득하는 함수이다.
CollectedCoinCount 를 증가시키고 만약 스폰된 코인의 양이 0보다 크고, 수집한 코인의 양이 스폰된 코인의 양과 크거나 같다면 레벨을 종료한다.
void AMyGameState::StartLevel()
{
SpawnedCoinCount = 0;
CollectedCoinCount = 0;
TArray<AActor*> FoundVolumes;
UGameplayStatics::GetAllActorsOfClass(GetWorld(), ASpawnVolume::StaticClass(), FoundVolumes);
const int32 ItemToSpawn = 10;
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
);
}
레벨이 시작되는 StartLevel 함수이다.
스폰된 코인과 수집된 코인의 수를 초기화하고 현재 볼륨을 찾는다. 이 볼륨이 1개 이상이라면 볼륨 중 아무거나 가져와(여기서는 0번 인덱스 볼륨) 그게 존재한다면 랜덤 아이템 스폰에서 가져와 아이템을 스폰하고 스폰된 코인 수를 증가시킨다.
또한 타이머를 작동시켜 제한 시간을 설정한다.
void AMyGameState::EndLevel()
{
GetWorldTimerManager().ClearTimer(LevelTimerHandle);
CurrentLevelIndex++;
if (CurrentLevelIndex >= MaxLevel)
{
OnGameOver();
return;
}
if (LevelMapNames.IsValidIndex(CurrentLevelIndex))
{
UGameplayStatics::OpenLevel(GetWorld(), LevelMapNames[CurrentLevelIndex]);
}
else
{
OnGameOver();
}
}
EndLevel 함수 구현이다.
타이머를 초기화시키고 CurrentLevelIndex 를 증가시킨다. 만약 이게 MaxLevel 보다 크거나 같다면 게임 오버를 시키고, 현재 레벨의 인덱스가 정상적이라면 새로운 레벨을 연다.
또한 코인을 먹었음을 알리려면 GameState->OnCoinCollected(); 를 CoinItem.cpp 파일의 ActivateItem 함수에 추가해주어야한다.

BP_MyGameState 에서 Details 탭의 Level > Level Map Names 인덱스를 3개 추가하여 아까 만든 레벨의 이름을 적어준다.
하지만 여기서 문제점은, 게임 스테이트는 레벨이 전환될 때 마다 생성이 되기때문에 안에 있는 변수, 즉 점수가 초기화가 된다.
이를 방지하기 위한 방법은 게임 인스턴스를 이용하는 방법이다.

우선 게임 인스턴스를 생성해준다.
#pragma once
#include "CoreMinimal.h"
#include "Engine/GameInstance.h"
#include "MyGameInstance.generated.h"
UCLASS()
class HW08_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;
}
소스 파일도 구현해준다.
void AMyGameState::AddScore(int32 Amount)
{
if (UGameInstance* GameInstance = GetGameInstance())
{
UMyGameInstance* MyGameInstance = Cast<UMyGameInstance>(GameInstance);
if (MyGameInstance)
{
MyGameInstance->AddToScore(Amount);
}
}
}
void AMyGameState::StartLevel()
{
if (UGameInstance* GameInstance = GetGameInstance())
{
UMyGameInstance* MyGameInstance = Cast<UMyGameInstance>(GameInstance);
if (MyGameInstance)
{
CurrentLevelIndex = MyGameInstance->CurrentLevelIndex;
}
}
SpawnedCoinCount = 0;
CollectedCoinCount = 0;
TArray<AActor*> FoundVolumes;
UGameplayStatics::GetAllActorsOfClass(GetWorld(), ASpawnVolume::StaticClass(), FoundVolumes);
const int32 ItemToSpawn = 10;
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
);
}
void AMyGameState::EndLevel()
{
GetWorldTimerManager().ClearTimer(LevelTimerHandle);
if (UGameInstance* GameInstance = GetGameInstance())
{
UMyGameInstance* MyGameInstance = Cast<UMyGameInstance>(GameInstance);
if (MyGameInstance)
{
AddScore(Score);
CurrentLevelIndex++;
MyGameInstance->CurrentLevelIndex = CurrentLevelIndex;
}
}
if (CurrentLevelIndex >= MaxLevel)
{
OnGameOver();
return;
}
if (LevelMapNames.IsValidIndex(CurrentLevelIndex))
{
UGameplayStatics::OpenLevel(GetWorld(), LevelMapNames[CurrentLevelIndex]);
}
else
{
OnGameOver();
}
}
그리고 게임 스테이트 소스 파일의 AddScore, StartLevel, EndLevel 함수를 위와 같이 바꾸어준다.

그리고 프로젝트 세팅에서 Game Instance Class 를 MyGameInstance 로 설정해준다.
마지막으로 BeginPlay 함수에
UE_LOG(LogTemp, Warning, TEXT("Level %d Starts!"), CurrentLevelIndex + 1);
이 줄을 추가해 레벨이 시작함을 알린다.

우선 UI 폴더를 만들어 그 안에 우클릭 후, User Interface > Widget Blueprint 를 선택해준다.


User Widget 을 눌러 생성해준다.


왼쪽 Palette 탭에서 PANEL > Canvas Panel 을 가운데에 있는 Designer 창에 드래그하여 놔준다.

그 다음엔 COMMON > Text 를 드래그 앤 드랍 해준다.

Details 탭에서 Content > Text 를 Score: 100 로 설정해주고, (임시로 점수가 떴을 때를 확인하기 위해 표시해두었다.)
Appearance > Font > Size 의 값을 60으로 바꾸어 폰트의 크기를 키워줬다.

또한 Justification 을 통해 중앙 정렬을 해주었다

마지막으로 대충 위치를 화면 좌측 상단에 잡아주었고, 같은 방식으로 타이머오 우측 상단에 표기해두었다.
또한 현재 레벨을 화면 중앙 상단에 표기했다.

이제 이 UI들은 PlayerController 에서 담당하기 때문에 새로 하나 만들어준다.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/PlayerController.h"
#include "MyPlayerController.generated.h"
UCLASS()
class HW08_API AMyPlayerController : public APlayerController
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HUD")
TSubclassOf<UUserWidget> HUDWidgetClass;
};
헤더 파일을 구성해주고,
#include "MyPlayerController.h"
#include "Blueprint/UserWidget.h"
void AMyPlayerController::BeginPlay()
{
Super::BeginPlay();
if (HUDWidgetClass)
{
UUserWidget* HUDWidget = CreateWidget<UUserWidget>(this, HUDWidgetClass);
if (HUDWidget)
{
HUDWidget->AddToViewport();
}
}
}
소스 파일도 모두 구현해준다.

그런 다음 빌드를 위해 Build.cs 파일로 들어가준다.

해당 부분에 "UMG" 를 추가해주면 된다.
추가한 뒤 빌드를 하면 잘 빌드된다.


언리얼 에디터 돌아가서 우선 컨트롤러를 블루프린트에 상속시키고, Details 탭의 UI > HUDWidget Class 를 WBP_HUD 로 설정해주었다.

그런 다음 프로젝트 설정으로 넘어가, 컨트롤러를 현재 게임모드에 넣어주었다.

그리고 UI의 크기가 화면에 제대로 들어왔는지 확인하기 위해 플레이 버튼 옆에 ...을 눌러 New Editor Window (PIE) 로 바꿔주고, 아래에 있는 Advanced Settings... 에 들어가준다.

Game Viewport Settings > New Viewport Resolution 의 뷰포트 값을 아까 내가 설정한 1920x1080 으로 바꿔준다.

실행해보니 화면에 잘 들어오는 듯하다.
AMyPlayerController();
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HUD")
TSubclassOf<UUserWidget> HUDWidgetClass;
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "HUD")
UUserWidget* HUDWidgetInstance;
UFUNCTION(BlueprintCallable, Category = "HUD")
UUserWidget* GetHUDWidget() const;
다음으로 컨트롤러 헤더 파일에 HUD 관련해서 추가해주고,
AMyPlayerController::AMyPlayerController()
:
HUDWidgetClass(nullptr)
HUDWidgetInstance(nullptr)
{
}
UUserWidget* AMyPlayerController::GetHUDWidget() const
{
return HUDWidgetInstance;
}
소스 파일에도 추가해준다.
void UpdateHUD();
다음으로는 게임 스테이트 헤더 파일에 HUD 업데이트 함수를 선언하고,
#include "MyPlayerController.h"
소스파일에서 구현하기 위해 헤더파일을 추가하고,
void AMyGameState::UpdateHUD()
{
if (APlayerController* PlayerController = GetWorld()->GetFirstPlayerController())
{
if (AMyPlayerController* MyPlayerController = Cast<AMyPlayerController>(PlayerController))
{
if (UUserWidget* HUDWidget = MyPlayerController->GetHUDWidget())
{
if (UTextBlock* TimeText = Cast<UTextBlock>(HUDWidget->GetWidgetFromName(TEXT("Timer"))))
{
float RemainingTime = GetWorldTimerManager().GetTimerRemaining(LevelTimerHandle);
TimeText->SetText(FText::FromString(FString::Printf(TEXT("Time: %.1f"), RemainingTime)));
}
if (UTextBlock* ScoreText = Cast<UTextBlock>(HUDWidget->GetWidgetFromName(TEXT("Score"))))
{
if (UGameInstance* GameInstance = GetGameInstance())
{
UMyGameInstance* MyGameInstance = Cast<UMyGameInstance>(GameInstance);
if (MyGameInstance)
{
ScoreText->SetText(FText::FromString(FString::Printf(TEXT("Score: %d"), MyGameInstance->TotalScore)));
}
}
}
if (UTextBlock* LevelIndexText = Cast<UTextBlock>(HUDWidget->GetWidgetFromName(TEXT("Level"))))
{
LevelIndexText->SetText(FText::FromString(FString::Printf(TEXT("Level %d"), CurrentLevelIndex + 1)));
}
}
}
}
}
함수를 구현해주었다.
또한 UpdateHUD 함수를 StartLevel 함수에도 추가해주었다.
void AMyGameState::OnGameOver()
{
UpdateHUD();
UE_LOG(LogTemp, Warning, TEXT("Game Over!"));
}
게임 오버 함수도 이제 구현해주었다.
FTimerHandle HUDUpdateTimerHandle;
추가적으로 헤더 파일에 타이머 핸들러를 하나 더 생성해주고,
GetWorldTimerManager().SetTimer(
HUDUpdateTimerHandle,
this,
&AMyGameState::UpdateHUD,
0.1f,
false
);
소스 파일 BeginPlay 에 추가해준다.
AMyGameState* MyGameState = GetWorld() ? GetWorld()->GetGameState<AMyGameState>() : nullptr;
if (MyGameState)
{
MyGameState->UpdateHUD();
}
컨트롤러 소스 파일에서도 초기화 해준다.

우선 위젯 블루프린트 하나를 만들어준다.


그런 다음 아까와 같이 Canvas Panel 을 깔고, COMMON > Border 을 그 아래에 배치한다.

Details 탭에서 Size 를 Canvas Panel 과 맞춰준다.

그러면 배경이 생긴다.

Details 탭의 Appearance > Brush Color 에서 색상도 변경할 수 있다.
나는 검정색으로 칠해두었다.




COMMON > Button 도 하이어라키에서 다음과 같이 배치해준다.




이번에는 Text 를 Button 아래에 배치해주고 내용을 'Start', 색상은 검정에 투명도 70, 폰트 크기는 70으로 설정해주었다.


메인 메뉴를 만들거기 때문에 레벨을 하나 따로 만들어주고 기본 레벨 설정을 메뉴 레벨로 바꾸어주었다.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Menu")
TSubclassOf<UUserWidget> MainMenuWidgetClass;
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "Menu")
UUserWidget* MainMenuWidgetInstance;
UFUNCTION(BlueprintCallable, Category = "Menu")
void ShowGameHUD();
UFUNCTION(BlueprintCallable, Category = "Menu")
void ShowMainMenu(bool bIsRestart);
UFUNCTION(BlueprintCallable, Category = "Menu")
void StartGame();
컨트롤러 헤더 파일에 해당 모드들을 추가해준다.
MainMenuWidgetClass(nullptr),
MainMenuWidgetInstance(nullptr)
소스 파일에선 해당 코드들을 추가해 초기화를 해준다.
void AMyPlayerController::ShowMainMenu(bool bIsRestart)
{
if (HUDWidgetInstance)
{
HUDWidgetInstance->RemoveFromParent();
HUDWidgetInstance = nullptr;
}
if (MainMenuWidgetInstance)
{
MainMenuWidgetInstance->RemoveFromParent();
MainMenuWidgetInstance = nullptr;
}
if (MainMenuWidgetClass)
{
MainMenuWidgetInstance = CreateWidget<UUserWidget>(this, MainMenuWidgetClass);
if (MainMenuWidgetInstance)
{
MainMenuWidgetInstance->AddToViewport();
bShowMouseCursor = true;
SetInputMode(FInputModeUIOnly());
}
if (UTextBlock* ButtonText = Cast<UTextBlock>(MainMenuWidgetInstance->GetWidgetFromName(TEXT("StartButtonText"))))
{
if (bIsRestart)
{
ButtonText->SetText(FText::FromString(TEXT("Restart")));
}
else
{
ButtonText->SetText(FText::FromString(TEXT("Start")));
}
}
}
}
메인 메뉴를 띄우는 함수와,
void AMyPlayerController::ShowGameHUD()
{
if (HUDWidgetInstance)
{
HUDWidgetInstance->RemoveFromParent();
HUDWidgetInstance = nullptr;
}
if (MainMenuWidgetInstance)
{
MainMenuWidgetInstance->RemoveFromParent();
MainMenuWidgetInstance = nullptr;
}
if (HUDWidgetClass)
{
HUDWidgetInstance = CreateWidget<UUserWidget>(this, HUDWidgetClass);
if (HUDWidgetInstance)
{
HUDWidgetInstance->AddToViewport();
bShowMouseCursor = false;
SetInputMode(FInputModeGameOnly());
}
AMyGameState* MyGameState = GetWorld() ? GetWorld()->GetGameState<AMyGameState>() : nullptr;
if (MyGameState)
{
MyGameState->UpdateHUD();
}
}
}
게임 HUD 를 띄우는 함수를 구현해줬다.
void AMyPlayerController::StartGame()
{
if (UMyGameInstance* MyGameInstance = Cast<UMyGameInstance>(UGameplayStatics::GetGameInstance(this)))
{
MyGameInstance->CurrentLevelIndex = 0;
MyGameInstance->TotalScore = 0;
}
UGameplayStatics::OpenLevel(GetWorld(), FName("Level_1"));
}
또한 게임을 시작하는 함수도 구현했다.
if (APlayerController* PlayerController = GetWorld()->GetFirstPlayerController())
{
if (AMyPlayerController* MyPlayerController = Cast<AMyPlayerController>(PlayerController))
{
MyPlayerController->ShowGameHUD();
}
}
게임 스테이트 소스 파일의 StartLevel 함수에 해당 코드를 추가해준다.
void AMyGameState::OnGameOver()
{
if (APlayerController* PlayerController = GetWorld()->GetFirstPlayerController())
{
if (AMyPlayerController* MyPlayerController = Cast<AMyPlayerController>(PlayerController))
{
MyPlayerController->ShowMainMenu(true);
}
}
}
OnGameOver 함수는 다음과 같이 바꿔준다.

이제 컨트롤러 블루프린트에서 Menu > Main Menu Widget Class 를 WBP_MainMenu 로 변경해주고,

WBP_MainMenu 의 StartButton 으로 가서 Is Variable 을 켜준다.

그 다음 아래에 보면 Events > On Clicked 가 있는데 여기서 + 버튼을 눌러준다.

다음과 같이 로직을 구현해준다.
void AMyPlayerController::BeginPlay()
{
Super::BeginPlay();
FString CurrentMapName = GetWorld()->GetMapName();
if (CurrentMapName.Contains("MenuLevel"))
{
ShowMainMenu(false);
}
}
다시 컨트롤러 소스 파일로 와서 BeginPlay 에 있던 코드들을 지우고 다시 짜준다.
https://drive.google.com/file/d/1r308wwFahDqwGCVpMs95bQ7WrF1k8XOI/view?usp=sharing
잘 작동한다.
강의를 보면서 진행했는데 자꾸 파일 여기저기 왔다갔다해서 너무 헷갈렸다... 강의를 볼 때와 그걸 작성할 때에는 분명 이해가 됐는데 처음부터 쭉 보려하니 길어서 그런지 머리에 잘 들어오지 않는다.
게다가 실시간으로 til 을 쓰려고 하니 vs 에서 코드 바꾸고... til 에서 다시 찾아서 코드 바꾸고 하는게 굉장히 불편했다...
그냥 모두 마무리하고 한 번에 쓰는게 좋은지 실시간으로 하는게 좋은지 아직은 잘 모르겠다.
실시간으로 해도 내가 제대로 바꾸고 바뀐 코드를 제대로 적었는지도 헷갈린다.
다시 원래 방식대로 til 을 작성해야겠다.
그래도 이번에 제대로 된 게임을 cpp 을 통해 만들어 본 느낌이라 어느정도 성취감이 있긴하다.
다음에 할 팀 프로젝트에 도움이 되었으면 좋겠다.
https://github.com/dfdeer/HW08.git
제대로 til 에 작성했는지 잘 모르겠어서 Git 도 올려놓는다.