언리얼엔진 학습 및 과제

게임 루프 및 UI 재설계 과제 (7)

park94128 2025. 10. 16. 22:39

14. 비주얼 업그레이드

A. 아이템 액터 돌리기

계속 테스트를 해봤는데 뭔가 심심해 보인다. 적어도 아이템이 가만히 있지 않고 회전한다면 덜 심심하지 않을까?

 

private:
    //최소 회전 속도
    UPROPERTY(EditDefaultsOnly, Category="Item", meta = (AllowPrivateAccess=true))
    float minRotatingSpeed;

    //최대 회전 속도
    UPROPERTY(EditDefaultsOnly, Category="Item", meta = (AllowPrivateAccess=true))
    float maxRotatingSpeed;

    //정해진 회전 속도
    FRotator rotatingValue;

아이템 액터의 헤더에 회전 속도를 받는 변수를 추가한다. 그냥 돌리기만 하기 보다는 아이템 마다 회전 속도가 다르면 재밌지 않을까 해서 최소 회전 속도, 최대 회전 속도를 받게 했다.

그 밑에는 두 변수 사이의 값을 저장하는 변수를 추가한다.

 

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

    //최대 회전 속도가 최소 회전 속도보다 낮으면 안됨
    if (minRotatingSpeed > maxRotatingSpeed)
       maxRotatingSpeed = minRotatingSpeed;

    //이 아이템의 회전 속도
    float speed = FMath::RandRange(minRotatingSpeed, maxRotatingSpeed);
    rotatingValue = FRotator(0, speed, 0);
}

BeginPlay에서 두 값 사이의 무작위 수를 정한다. 이 값이 아이템의 회전 속도가 된다.

 

void AItemActor::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    AddActorWorldRotation(rotatingValue);
}

Tick함수에서 정해진 회전 속도를 사용하여 회전을 구현한다.

아이템 블루프린트를 열어서 회전 값을 정한다. 여기서 생각해보니 음수 값이 들어가면 반대로 돌아갈 테니 최댓값을 양수로, 최솟값을 음수로 설정하여 서로 반대로 돌아가는 아이템이 존재할 수도 있게 만들었다.

 

이후 테스트해보니 예상한 대로 작동한다.

B. UI를 더 잘보이게

대체로 밝은 화면에 흰 글씨라서 점수와 웨이브가 잘 안보이는 문제가 있다.

 

위젯 블루프린트의 팔레트 중 보더(Border) 라는 것이 있는데 이것을 2개 추가한다.

 

추가한 각 보더의 크기와 위치를 텍스트에 맞게 조절한다. 텍스트에 설정한 값보다는 약간 크게 만들었다.

 

텍스트가 흰색이니 보더의 브러시 컬러를 검은색으로 설정한다.

계층구조 패널에서 각 텍스트를 보더에 드래그 하면 보더의 하위 객체가 된다. 그런데 위쪽으로 쏠린 느낌이 든다.

 

텍스트의 디테일 화면을 보면 보더 밑으로 들어갔기 때문에 내용이 달라졌다. 여기서 세로 정렬을 변경하면 해결된다.

 

컴파일후 저장하면 게임 실행 화면에도 잘 반영된다. 검은 배경 위에 하얀 글씨가 있으니 잘 보인다.

 

15. 게임 모드가 게임 스테이트의 영역을 침범하고 있다.

현재 점수와 웨이브를 게임 모드에서 관리하고 있다. 그런데 만들다 알게된 사실이 있는데 사실 점수와 같이 게임 진행 상황에 대한 내용은 게임 스테이트(Game State)에서 다룬다고 한다. 여태껏 게임 스테이트가 할 일을 게임 모드의 일로 잘못 알고 있었다.

코드를 생성한다. 이때 게임 스테이트 베이스를 선택하여 생성한다.

 

원복된 게임모드(좌), 코드를 옮겨 만든 게임 스테이트(우)

게임 모드에서 기존에 작성된 코드를 제외한 모든 코드를 게임 스테이트로 옮긴다. include 명령도 당연히 포함한다. 이런 식으로 게임 모드의 코드를 처음 상태로 원복시킨다.

 

private:
    //플레이어 컨트롤러
    UPROPERTY()
    class AMyPlayerController* playerController;

옮기고 나서 보니 게임 컨트롤러를 통해 위젯에 접근하는 구간이 2개 있는데 여기서 플레이어 컨트롤러를 따로 변수로 저장하면 캐스팅 과정이 생각될 것이니 더 좋아 보인다. 위젯을 저장하지 않는 이유는 위젯 생성 시점이 플레이어 컨트롤러의 BeginPlay라서 다른 BeginPlay에서 접근하여 생기는 문제를 방지하기 위함이다.

 

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

    playerController = Cast<AMyPlayerController>(GetWorld()->GetFirstPlayerController());
    if (playerController == nullptr)
    {
       UE_LOG(LogTemp, Error, TEXT("PlayerController is null"));
       return;
    }

    score = 0;
    currentWave = 0;
    NextWave();
}

다만 플레이어 컨트롤러는 게임 스테이트의 BeginPlay 시점에 이미 존재하기 때문에 문제가 없다. 그래서 게임 스테이트의 BeginPlay에서 캐스팅하여 저장한다.

 

AMyGameState* gameMode = Cast<AMyGameState>(GetWorld()->GetGameState());
if (gameMode != nullptr)
{
    //점수 추가
    gameMode->AddScore(COIN_SCORE);
    //모든 동전 획득시 다음 웨이브
    if (--spawnCoinCount == 0)
       gameMode->NextWave();
}

이제 다른 클래스에서 게임 모드에 접근하는 코드를 모두 게임 스테이트로 접근하게 변경한다.

 

월드 세팅 패널에서 게임 스테이트 클래스를 변경한다.

 

16. 게임을 더 재미있게 변경

A. 캐릭터 이동 속도 증가

캐릭터의 이동 속도는 원래 500이었는데 좀 느리다는 생각이 들었다. 그래서 이를 1000으로 바꿨다.

 

private:
    //기본 이동 속도
    const float DEFAULT_MOVE_SPEED = 1000.0f;
    //디버프시 이동 속도
    const float DEBUFF_MOVE_SPEED = 300.0f;

캐릭터 코드에 만들어놨던 const 변수의 값도 변경한다. 하지만 여기서 좀 사악한(?) 생각이 들었는데 디버프시 이동속도는 변경하지 않고 그대로 뒀다. 해골 아이템의 악형향이 더 커질 것이다.

 

B. 미끄러운 바닥

캐릭터의 무브먼트의 내용을 잘 보면 이동 속도뿐 만이 아니라 다른 값도 존재한다. 여기서 지면 마찰과 감속 걷기 제동 값이 눈에 들어왔다. 이 값을 매우 적은 값으로 설정하니 제동이 힘들어졌다. 마치 기름이나 얼음 위를 걷는 것처럼 말이다. 그래서 이를 이용하여 게임의 룰을 변경할 것이다.

 

public:
    //속도 디버프
    void SpeedDebuff();

    //속도 복구
    void RestoreSpeed();

    //제동력과 마찰값 감소
    void SlipperyFloor();

    //제동력과 마찰값 복구
    void UnslipperyFloor();

private:
    //기본 이동 속도
    const float DEFAULT_MOVE_SPEED = 1000.0f;
    //기본 마찰값
    const float DEFAULT_MOVE_FRICTION = 8.0f;
    //기본 제동력
    const float DEFAULT_MOVE_DECELERATION = 2000.0f;
    
    //디버프시 이동 속도
    const float DEBUFF_MOVE_SPEED = 300.0f;

    //얼음 바닥의 마찰값
    const float ICE_MOVE_FRICTION = 1.0f;
    //얼음 바닥의 제동력
    const float ICE_MOVE_DECELERATION = 100.0f;

일단 코드로 사용하기 위해 캐릭터 코드의 헤더에 함수와 const변수를 추가로 선언한다.

 

void ANBC_Task8Character::SlipperyFloor()
{
    GetCharacterMovement()->GroundFriction = ICE_MOVE_FRICTION;
    GetCharacterMovement()->BrakingDecelerationWalking = ICE_MOVE_DECELERATION;
}

void ANBC_Task8Character::UnslipperyFloor()
{
    GetCharacterMovement()->GroundFriction = DEFAULT_MOVE_FRICTION;
    GetCharacterMovement()->BrakingDecelerationWalking = DEFAULT_MOVE_DECELERATION;
}

추가로 선언한 함수의 내용 정의

 

C. 게임 룰 변경

게임 진행시 웨이브를 아래와 같이 변경한다.
- 1번째 웨이브는 동전 아이템만 등장한다.
- 2번째 웨이브는 해골 아이템과 구급약 아이템이 등장한다.
- 3번째 웨이브는 동전 아이템만 등장하지만 바닥이 미끄럽다.
- 4번째부터 위를 반복한다.

 

구체적으로 어떻게 변경할 것인지는 다음과 같다.
- 동전 아이템 생성 규칙은 변하지 않는다.
- 2번째 웨이브 마다 현재 웨이브만큼 해골, 구급약 아이템을 생성한다.
- 해골, 구급약 아이템의 최대 생성 개수를 늘린다.
- 3번째 웨이브 마다 바닥이 미끄럽게 만들고 아니면 원래대로 만든다.

 

private:
    //시작시 생성하는 동전 아이템 개수
    const int START_COIN_COUNT = 10;

    //최대 생성 가능한 동전 아이템 개수
    const int MAX_COIN_COUNT = 50;

    //1회 반복마다 진행할 웨이브 수
    const int WAVE_ONE_CYCLE = 3;

    //매 반복마다 이 웨이브에서 해골, 구급약 아이템 생성
    const int SUB_ITEM_WAVE = 2;

    //매 반복마다 이 웨이브에서 바닥이 미끄러움
    const int SLIPPERY_WAVE = 3;

    //최대 생성 가능한 해골, 구급약 아이템 개수
    const int MAX_SUB_ITEM_COUNT = 20;

게임 스테이트로 옮겨진 코드 중 const 변수를 위 규칙에 맞게 변경한다.

 

void AMyGameState::NextWave()
{
    //웨이브 증가
    ++currentWave;

    //UI 반영
    UMainUserWidget* widget = playerController->GetUserWidget();
    widget->SetWave(currentWave);

    //맵에 남아있는 모든 아이템 제거
    AItemManager::GetInstance()->DeleteAllItems();

    //동전 아이템 생성 (시작 개수 + (웨이브 - 1) * 2), 최대 개수 준수
    int makeCoinCount = START_COIN_COUNT + (currentWave - 1) * 2;
    if (makeCoinCount > MAX_COIN_COUNT)
       makeCoinCount = MAX_COIN_COUNT;
    
    AItemManager::GetInstance()->SpawnItems(1, makeCoinCount);

    //매 반복마다 2번째 웨이브에서 해골, 구급약 아이템 생성
    int waveCheck = currentWave % WAVE_ONE_CYCLE;
    if (waveCheck == 2)
    {
       //현재 웨이브 만큼 각각 생성, 최대 개수 준수
       int makeSubItemCount = currentWave;
       if (makeSubItemCount > MAX_SUB_ITEM_COUNT)
          makeSubItemCount = MAX_SUB_ITEM_COUNT;

       AItemManager::GetInstance()->SpawnItems(2, makeSubItemCount);
       AItemManager::GetInstance()->SpawnItems(3, makeSubItemCount);
    }
    
    //매 반복마다 3번째 웨이브에서 바닥이 미끄러움
    ANBC_Task8Character* player = Cast<ANBC_Task8Character>(playerController->GetCharacter());
    if (waveCheck == 0)
       player->SlipperyFloor();
    else
       player->UnslipperyFloor();
}

변경된 규칙에 맞도록 다음 웨이브로 넘어가는 함수를 변경한다.

 

D. 또 다른 디버프 효과 만들기

지금은 해골 아이템을 획득하면 속도 감소 효과만 적용된다. 하지만 재미를 위해 다른 효과도 적용될 수 있도록 만든다.

private:
    //반대방향으로 이동
    bool reverseMoving = false;

구현할 내용은 반대방향 이동이다. 디버프가 적용되는 동안 이동 키가 반대방향으로 적용되도록 할 생각이다. 게임 캐릭터 코드에 반대 방향으로 이동하는지 판단하는 변수를 만든다.

 

public:
    //속도 디버프
    void SpeedDebuff();

    //이동 방향 디버프
    void ReverseMovingDebuff();

    //이동 디버프 제거
    void RestoreMovement();

기존에 만든 속도 디버프 함수 밑에 이동 방향을 뒤집는 함수를 선언한다. 이 디버프도 구급약 아이템 하나로 회복이 가능하게 만들 생각이라서 이동 속도 복구 함수의 이름을 변경했다.

 

void ANBC_Task8Character::ReverseMovingDebuff()
{
    reverseMoving = true;
}

void ANBC_Task8Character::RestoreMovement()
{
    GetCharacterMovement()->MaxWalkSpeed = DEFAULT_MOVE_SPEED;
    reverseMoving = false;
}

이동 방향 뒤집기 함수에서 반대 방향으로 이동하도록 변수 값을 true로 만든다. 이름을 변경한 함수는 원래 방향대로 이동하도록 변수 값을 false로 만드는 내용을 추가한다.

 

기존에 작성된 코드 중 캐릭터 이동을 담당하는 함수의 중간에 디버프시 반대로 이동하도록 값을 변경하는 기능을 만든다. 함수의 구조가 생각보다 단순해서 어렵지 않게 변경이 가능하다.

 

case 2://해골
    if (FMath::RandRange(0, 1) == 0)
       playerCharacter->SpeedDebuff();
    else
       playerCharacter->ReverseMovingDebuff();
    break;

마지막으로 아이템 매니저의 아이템 효과 적용 함수에서 해골 아이템 획득시 두 디버프 중 무작위로 하나를 실행하는 구조로 변경한다.

 

17. 제한 시간

지금은 한 번 게임을 시작하면 게임 자체를 종료하지 않는 이상 영원히 진행한다. 그래서 시간 제한을 도입하려고 한다. 이에 대하여 게임에 다음 규칙이 추가된다.

- 한 웨이브마다 제한 시간은 60

- 웨이브 종료시 남은 시간에 따라 점수 획득

- 시간이 초과되기 전에 동전을 획득해야 하며 실패시 게임 종료

 

private:
    //흘러가는 제한 시간
    void TimeLimitCounting();

    //웨이브당 제한 시간 (초)
    const float TIME_LIMIT_SECOND = 60.0f;

    //제한 시간을 세는 단위 (초)
    const float TIME_LIMIT_COUNT_AMOUNT = 0.1f;

    //제한 시간용 핸들
    FTimerHandle timerHandle;

    //현재 남은 시간
    float remainingTime = 0;

    //1초를 세기 위한 변수
    float timeCounting = 0;

게임 스테이트에 추가되는 변수는 다음과 같다.

- 웨이브당 제한 시간과 제한 시간을 세는 단위를 const 변수로 추가한다.

- 제한 시간은 하술할 타임 매니저를 사용하여 구현한다. 그럴려면 FTimerHandle타입의 변수가 필요하다.

- 남은 제한시간을 저장하는 변수와 1초를 세기 위한 변수를 선언한다.

 

여기에 추가로 제한 시간 구현을 위한 함수 하나를 선언한다. 이 함수의 내용은 하술한다.

 

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

    playerController = Cast<AMyPlayerController>(GetWorld()->GetFirstPlayerController());
    if (playerController == nullptr)
    {
       UE_LOG(LogTemp, Error, TEXT("PlayerController is null"));
       return;
    }

    score = 0;
    currentWave = 0;
    NextWave();

    //제한 시간 적용 시작
    GetWorldTimerManager().SetTimer(timerHandle, this, &AMyGameState::TimeLimitCounting , TIME_LIMIT_COUNT_AMOUNT, true);
}

BeginPlay 함수에서 GetWorldTimerManager 함수를 통해 타임 매니저를 호출한다. 타임 매니저는 SetTimer함수가 있는데 이 함수를 통해 지연 실행, 반복 실행을 구현할 수 있다. 이 함수에서 사용하는 매개변수는 순서대로 다음과 같다.

- 1번째는 FTimerHandle 변수. 만약 SetTimer를 여러 개 실행할 경우 이 변수를 통해 구분이 가능하다.

- 2번째는 SetTimer를 실행한 객체인 this를 넣으면 된다.

- 3번째는 실행할 함수를 가리키는 함수 주소가 필요하다.

- 4번째는 실행할 지연 시간 이다.

- 5번째는 반복 여부. true라면 지연 시간마다 계속 실행하고 false라면 1번만 실행한다.

 

여기서 3번째에 들어갈 함수는 다음과 같다.

void AMyGameState::TimeLimitCounting()
{
    timeCounting += TIME_LIMIT_COUNT_AMOUNT;

    //1초마다 시간 감소 적용
    if (timeCounting >= 1.0f)
    {
       timeCounting -= 1.0f;
       remainingTime--;
       UE_LOG(LogTemp, Warning, TEXT("Time : %d"), remainingTime);

       //0초 도달시 게임 종료
       if (remainingTime == 0)
          UE_LOG(LogTemp, Warning, TEXT("Game Over"));
    }
}

내 코드 기준으로는 0.1초마다 이 함수를 실행한다. 그리고 함수 실행마다 이 0.1초를 계속 더한다. 더한 값이 1이 되면 (, 1초마다) 남은 시간이 1 감소하고 0이 되면 게임이 종료된다. (아직 게임 종료를 구현하지 않았으니 로그로 대신한다.)