Board
URR은 4 x 4의 타일을 통해 숫자(유닛)를 나타내며, 사용자 입력에 의해 해당 숫자들이 합쳐지는 형식이다.
숫자를 나타내는 타일 16개가 따로 존재하면 입력에 의해 합쳐지거나 움직이는 등의 동작이 쉽지 않을 것이다.
따라서, 타일들을 하나의 Pawn으로 묶어 관리하고 해당 클래스에서 사용자 입력을 받아 처리할 예정이다.
Tile
Tile은 유닛과 해당 유닛의 숫자를 나타내는 액터이다.
해당 액터에서 타일의 유닛의 포인터를 관리하여 Spawn, Destory 등을 처리한다.
Board의 BeginPlay에서 총 16개의 Tile을 생성하여 이차원 배열을 통해 관리한다.
이차원 배열을 사용한 이유는 네 방향의 이동이 가능한데, 이 작업에서 간편하게 계산하기 위함이다.
또한, 유닛의 단계(1, 2, 4, ... 1024)를 나타내는 역할을 한다.
즉, 타일은 타일위에 유닛의 생성과 삭제, 표시등을 담당하는 액터이다.
Board: 타일 이동
Board는 타일을 관리하며 플레이어가 빙의하는 Pawn이다.
해당 클래스에서는 플레이어의 입력을 처리한다.
URR은 2048게임처럼 동일한 두 타일을 합쳐 다음 단계로 만드는 것이 핵심이다.
w, a, s, d를 이용해 타일을 이동하는데, 빈 타일이 아닌 타일만 이동한다.
모든 타일은 한 방향으로 이동하며 인접한 타일에 같은 숫자가 있다면 합쳐진다.
이는, 연쇄적으로 진행된다.
이 작업은 다음과 같은 로직으로 진행된다.
- 이동하는 방향의 끝 부분에서부터 가장 가까운 타일을 찾는다.
- 발견한 타일(a)에서 이동 방향으로 인접한 타일(b)을 찾는다.
- 만약 두 타일이 같다면, 발견한 타일(a)의 유닛을 삭제한 뒤 인접한 타일(b)의 유닛을 다음 단계로 만든다.
- 만약 두 타일이 다르다면 발견한 타일(a)을 최대한 이동방향으로 밀어낸다.
이 작업을 위해, Heap을 사용했다.
Heap을 사용한 이유는 타일들은 Board에서 이차원 배열로 관리하고 있기 때문에 해당 타일의 idx를 통해 위치를 특정할 수 있다.
즉, 이동 방향에 따라 가장 먼저 이동해야 하는 타일을 idx로 쉽게 가져올 수 있다.
예를 들어, 왼쪽으로 이동하는 상황을 가정해 보자.
왼쪽으로 타일을 이동시키기 위해서는 1을 먼저 이동시켜야 할 것이다.
즉, Tiles[0][2]를 먼저 이동시킨 뒤, Tiles[0][3]을 이동시킨다.
따라서, j의 idx를 Heap을 통해 정렬된 Top을 뽑아 처리하면 된다.
이때는 작은 idx를 우선적으로 뽑아야 하며, 만약 반대로 이동하면 idx가 큰 타일부터 처리한다.
결국 내가 필요한 것은 우선순위 큐이다.
하지만, UE에서는 이를 지원하지 않는다.
대신, TArray를 Heap으로 만들어 사용할 수 있다.
우선순위 큐도 Heap을 기반으로 만들어져 있기 때문에 문제되지 않는다.
TArray를 Heap으로 사용하는 방법은 다음과 같다.
ExistSet.Heapify();
https://docs.unrealengine.com/4.27/en-US/API/Runtime/Core/Containers/TArray/Heapify/2/
타일을 이동하는 세부적인 로직은 다음과 같다.
- 유닛이 존재하는 타일과 빈 타일을 분리하여 Heap에 저장한다.
- 유닛이 존재하는 타일을 위의 로직에 따라 처리한다.
- 타일을 이동시키거나, 합쳐져 빈 타일을 갱신한다.
- 만약 타일이 같아 합쳐지면 유닛이 존재하는 타일 Heap에 다시 넣어 로직을 처리한다.
void AURRBoard::MoveLeft()
{
for (int i = 0; i < 4; i++)
{
TArray<int> ExistSet;
TArray<int> EmptySet;
for (int j = 0; j < 4; j++)
{
if (Tiles[i][j]->IsEmpty())
{
EmptySet.Add(j);
}
else ExistSet.Add(j);
}
ExistSet.Heapify();
EmptySet.Heapify();
while(!ExistSet.IsEmpty())
{
int currentIdx;
ExistSet.HeapPop(currentIdx, true);
int currentRank = Tiles[i][currentIdx]->GetRank();
int prevRank = -1;
int prevIdx = -1;
for (int j = currentIdx - 1; j >= 0; j--)
{
if (!Tiles[i][j]->IsEmpty())
{
prevRank = Tiles[i][j]->GetRank();
prevIdx = j;
break;
}
}
if (currentRank == prevRank)
{
Tiles[i][currentIdx]->DestroyUnit();
Tiles[i][prevIdx]->RankUpUnit();
ExistSet.HeapPush(prevIdx);
}
else
{
if (EmptySet.IsEmpty()) continue;
if (currentIdx < EmptySet.HeapTop()) continue;
int firstEmpty;
EmptySet.HeapPop(firstEmpty, true);
Tiles[i][currentIdx]->DestroyUnit();
Tiles[i][firstEmpty]->SpawnUnit(currentRank);
}
EmptySet.HeapPush(currentIdx);
}
}
}
이러한 로직을 방향에 맞게 설정하여 구현하면 된다.
Board: 플레이어
Board는 타일을 관리하는 것 이외에도 플레이어에 대한 정보를 처리하는 클래스이다.
플레이어의 체력, 코인, 증강 등에 대한 데이터와 이를 표시하는 Hud를 갖고 있다.
Hud는 다음과 같이 구성했다.
이러한 정보들은 Board의 AttributeSet을 통해 관리된다.
UCLASS()
class URR_API UPlayerAttributeSet : public UAttributeSet
{
GENERATED_BODY()
public:
UPlayerAttributeSet();
ATTRIBUTE_ACCESSORS(UPlayerAttributeSet, Coin);
ATTRIBUTE_ACCESSORS(UPlayerAttributeSet, Health);
ATTRIBUTE_ACCESSORS(UPlayerAttributeSet, MaxHealth);
ATTRIBUTE_ACCESSORS(UPlayerAttributeSet, Cost);
protected:
UPROPERTY(BlueprintReadOnly, Category = Stat, meta = (AllowPrivateAccess = true))
FGameplayAttributeData Coin;
UPROPERTY(BlueprintReadOnly, Category = Stat, meta = (AllowPrivateAccess = true))
FGameplayAttributeData Health;
UPROPERTY(BlueprintReadOnly, Category = Stat, meta = (AllowPrivateAccess = true))
FGameplayAttributeData MaxHealth;
UPROPERTY(BlueprintReadOnly, Category = Stat, meta = (AllowPrivateAccess = true))
FGameplayAttributeData Cost;
public:
virtual void PostGameplayEffectExecute(const struct FGameplayEffectModCallbackData& Data) override;
};