언리얼엔진 학습 및 과제

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

park94128 2025. 10. 20. 23:44

20. 과제 내용에 맞는 룰 변경

여기서 끝난 줄 알았는데 아니었다. 제출할 내용에 따르면 3개의 맵에서 최소 3웨이브 진행하는 방식을 요구하고 있다. 그래서 룰을 변경해야 한다.

- 타이틀 맵을 제외하고 총 3개의 맵을 사용한다.

- 각 맵 마다 3번 웨이브 진행한다.

- 게임 클리어 판정을 만든다. 조건은 3개의 맵 모두 통과하기로 한다.

 

A. 추가 맵 만들기

시간상의 문제로 기존 게임 맵을 복제했다. 그리고 기존 맵의 이름은 Main에서 Stage1로 변경했다. 복제한 나머지 맵의 이름은 각각 Stage2, Stage3으로 명명했다.

 

2번째 맵은 제일 바깥 바닥이 밑으로 내렸다. 만약 밑으로 빠지면 올라오기 위해 계단을 타고 올라가는 번거로운 과정이 있을 테니 시간이 걸릴 것이며 특히 바닥이 미끄러운 상태라면 큰 걸림돌이 될 것이다.

 

3번째 맵은 마지막인 만큼 어렵게 만들 생각이었다. 빛을 줄여 어둡게 만들었으며 가운데에 회전하는 장애물이 있다.

 

B. 3번째 맵 전용 장애물

장애물이 회전한다는 것은 곧 액터라는 의미다. 당연히 코드와 블루프린트가 존재한다.

 

public: 
    //회전 속도 증감
    void AccelerateOrDecelerate(bool acceleration);

    //회전 방향 변경
    void ChangeRotatingDirection();

private:
    //회전 속도
    UPROPERTY(EditAnywhere, Category = "Rotation Settings", meta = (AllowPrivateAccess = "true"))
    float rotatingSpeed;

    //최소 회전 속도
    UPROPERTY(EditAnywhere, Category = "Rotation Settings", meta = (AllowPrivateAccess = "true"))
    float minSpeed;

    //최대 회전 속도
    UPROPERTY(EditAnywhere, Category = "Rotation Settings", meta = (AllowPrivateAccess = "true"))
    float maxSpeed;

    //속도 증감시 단위 값
    UPROPERTY(EditAnywhere, Category = "Rotation Settings", meta = (AllowPrivateAccess = "true"))
    float accelerationMeasure;

    //반시계 여부
    UPROPERTY(EditAnywhere, Category = "Rotation Settings", meta = (AllowPrivateAccess = "true"))
    bool antiClock;

    //회전 속도에 의해 정해진 회전 값
    FRotator rotationValue;

헤더에 선언한 함수와 변수다. 이쯤이면 각각 어떤 목적으로 사용되는지 짐작할 수 있을 것이다.

 

이 코드를 상속받은 블루프린트에는 4개의 벽이 있다. 이 벽이 액터 중심 기준으로 회전하면서 플레이어를 방해한다.

 

맵이 어둡다는 것을 고려하여 벽 위쪽마다 광원을 추가했다.

 

C. 3번째 맵 전용 아이템

3번째 맵의 장애물이 그냥 돌기만 하면 재미가 없을 것 같아서 3번째 맵 전용 아이템을 추가했다. 위 사진 중 원형 화살표는 외부 에셋에서 가져왔지만 파란 화살표는 언리얼 엔진 자체에 있는 스태틱 메시를 사용했다.

왼쪽 : 속도 증가, 오른쪽 : 속도 감소

속도 증가와 속도 감소 모두 같은 메시라서 구분이 안될 수도 있어서 대신 속도를 다르게 했다. 속도 증가는 항상 빠르게, 속도 감소는 항상 느리게 움직인다.

 

속드 증가, 감소 아이템에 사용된 메시는 전체적으로 균일한 모양이라서 회전하는 모습이 잘 안보인다. 그래서 중심에서 약간 벗어나게 배치했다.

 

private:
    //아이템 액터 목록
    UPROPERTY(EditInstanceOnly, Category = "Item Actor", meta = (AllowPrivateAccess = true))
    TArray<TSubclassOf<AItemActor>> itemActors;

아이템 매니저에 새 아이템을 추가하려고 했는데 일일이 변수를 추가하려는게 귀찮다. 그래서 그냥 배열 1개로 만들었다. 이렇게 배열로 바꿨으니 아이템 생성 함수도 이에 맞게 수정해야 한다.

 

아이템 매니저는 맵마다 배치된 서로 다른 액터라서 이렇게 서로 다른 값을 할당하는 것이 가능하다.

 

public: 
   //아이템 생성
   void SpawnItems(int itemID, int spawnCount, bool addToAllItems = true);

새로 추가한 아이템은 웨이브와는 관련이 없다. 그래서 다음 웨이브로 넘어갈 때 삭제하거나 새로 생성하지 않는다. 이를 위해 아이템 생성 함수에 새로운 매개변수를 추가한다. 기존 아이템 생성 로직과 충돌하지 않아야 하니 이 매개변수는 기본값을 지정한다.

 

//스테이지3의 장애물 관련 아이템
case 4://속도 증가
case 5://속도 감소
case 6://방향 변경
    {
       ARotatingObstacle* rotatingObstacle = ARotatingObstacle::GetInstance();
       if (!::IsValid(rotatingObstacle))
          break;

       if (itemID == 6)
          rotatingObstacle->ChangeRotatingDirection();
       else
          rotatingObstacle->AccelerateOrDecelerate(itemID == 4);
          
       //적용한 아이템 재생성
       SpawnItems(itemID, 1, false);
    }
    break;

아이템 적용 함수에는 새 아이템 번호를 추가하여 적용하도록 만든다. 그리고 이 아이템은 얻고 나서 다시 생성하도록 만든다.

 

일단 여기까지 하고 이 아이템들을 3스테이지에만 생성하도록 하는 것은 나중에 구현한다.

 

D. 아이템 생성 개수 조절

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

    //2번째 웨이브에서 생성하는 해골, 구급약 아이템 개수
    const int SUB_ITEM_COUNT = 5;

    //웨이브당 제한 시간 (초)
    const int TIME_LIMIT_SECOND = 60;

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

웨이브 제한으로 인해 필요 없게 된 const 변수를 지운다. 그리고 기존 4 ~ 6 웨이브쯤 진행했을 때의 난이도 수준이 되도록 개수를 조절한다.

 

//동전 아이템 생성
AItemManager::GetInstance()->SpawnItems(1, START_COIN_COUNT);

//일반 웨이브 알림
if (currentWave == 1)
    widget->WaveAlert(EWaveType::Normal);

//2번째 웨이브에서 해골, 구급약 아이템 생성
if (currentWave == 2)
{
    AItemManager::GetInstance()->SpawnItems(2, SUB_ITEM_COUNT);
    AItemManager::GetInstance()->SpawnItems(3, SUB_ITEM_COUNT);

    //해골 웨이브 알림
    widget->WaveAlert(EWaveType::Skull);
}

//3번째 웨이브에서 바닥이 미끄러움
ANBC_Task8Character* player = Cast<ANBC_Task8Character>(playerController->GetCharacter());
if (currentWave == 3)
{
    player->SlipperyFloor();
    widget->WaveAlert(EWaveType::Ice);//얼름 웨이브 알림
}
else
{
    player->UnslipperyFloor();
}

필요 없게 된 변수를 지움에 따라 다음 웨이브로 넘어가는 함수가 간결해졌다.

 

E. 웨이브 제한 및 다음 맵 이동 구현

//4웨이브로 확인되면 다음 맵으로 이동
if (currentWave >= 4)
{
    return;
}

한 맵마다 3개의 웨이브를 통과하면 다음 맵으로 이동한다. 일단 다음 웨이브 진행 함수의 상단 부분에 4웨이브가 감지되면 원래 하던 다음 웨이브 시작을 하지 않고 다음 맵으로 넘어가게 만들어야 한다.

 

//게임 결과
enum class EGameResult
{
    None,
    GameOver,
    GameClear
};

게임 인스턴스 코드를 수정한다. 우선 클래스 위쪽에 enum을 하나 만든다. 게임 결과를 표현하는 enum이다.

 

private:
    //점수
    int score;
    //게임 결과
    EGameResult gameResult;
    //스테이지 번호
    int stage;
    //맵 이름 목록
    TArray<FName> mapNames;

boolgameOver 변수를 지우고 선언한 enum 타입인 gameResult 변수를 선언한다. 그리고 스테이지 번호와 맵 이름 목록이 필요하다.

 

public:
    EGameResult GetGameResult() const;
    void SetGameResult(EGameResult value);

    //게임 시작 (첫 번째 맵으로 이동)
    void GameStart();

    //다음 스테이지로 이동 (마지막 스테이지에서 실행하면 타이틀 맵으로)
    void GoToNextStage();

    //게임 오버
    void GameOver();

gameOver변수 대신 gameResult변수를 사용하니 이에 대한 Get, Set 함수를 변경한다.

스테이지 번호와 맵 이름은 클래스 외부에 직접 볼 수 없으며 내부 계산용으로 사용한다. 그래서 이들에 대한 Get, Set 함수는 없다.

게임 시작, 다음 스테이지, 게임 오버 함수를 선언한다.

 

void UMyGameInstance::Init()
{
    Super::Init();

    score = 0;
    gameResult = EGameResult::None;
    stage = 0;
    mapNames = { TEXT("Title"), TEXT("Stage1"), TEXT("Stage2"), TEXT("Stage3") };
}

Init함수에서 새로 만든 변수에 대한 초기화를 진행한다.

 

EGameResult UMyGameInstance::GetGameResult() const
{
    return gameResult;
}

void UMyGameInstance::SetGameResult(EGameResult value)
{
    gameResult = value;
}

void UMyGameInstance::GameStart()
{
    //게임 진행 내용 초기화
    score = 0;
    gameResult = EGameResult::None;
    //1번째 맵
    stage = 1;
    UGameplayStatics::OpenLevel(GetWorld(), mapNames[stage]);
}

void UMyGameInstance::GoToNextStage()
{
    //이 if문에 들어간다면 게임 클리어
    if (!mapNames.IsValidIndex(++stage))
    {
       gameResult = EGameResult::GameClear;
       stage = 0;
    }
       
    UGameplayStatics::OpenLevel(GetWorld(), mapNames[stage]);
}

void UMyGameInstance::GameOver()
{
    gameResult = EGameResult::GameOver;
    stage = 0;
    UGameplayStatics::OpenLevel(GetWorld(), mapNames[stage]);
}

gameOver에 대한 Get, Set 함수 대신 gameResult에 대한 Get, Set 함수로 변경했으니 함수 내용도 이에 맞게 바꾼다.

GameStart 함수는 필요한 변수를 초기화 하고 스테이지 1로 이동하는 기능을 담당한다.

GoToNextStage함수는 다음 스테이지로 이동하되 마지막 스테이지 였다면 게임 클리어 판정을 만들면서 타이틀 맵으로 이동한다.

GameOver함수는 말 그대로 게임 오버 판정을 만들면서 타이틀 맵으로 이동한다.

 

//4웨이브로 확인되면 다음 맵으로 이동
UMyGameInstance* myGameInstance = Cast<UMyGameInstance>(GetGameInstance());
if (currentWave >= 4)
{
    myGameInstance->SetScore(score);
    myGameInstance->GoToNextStage();
    return;
}

다시 게임 스테이트 코드로 이동한다. 다음 웨이브 시작 함수에서 4웨이브 이상일 경우 금방 만들었던 다음 스테이지 이동 함수를 호출한다.

 

//0초 도달시 게임 종료
if (remainingTime == 0)
{
    UMyGameInstance* myGameInstance = Cast<UMyGameInstance>(GetGameInstance());
    myGameInstance->SetScore(score);
    myGameInstance->GameOver();
}

게임 오버 함수를 만들었으니 게임 스테이트의 게임 오버 부분을 수정한다.

 

F. 게임 클리어 화면

private:
    //게임 클리어 텍스트
    UPROPERTY(EditAnywhere, meta = (allowPrivateAccess = true, BindWidget))
    UWidget* gameClearText;

 

타이틀 화면의 유저 위젯에서 새로운 텍스트를 추가한다. 게임 클리어시 사용할 것이다. 당연히 C++코드와 연동한다.

 

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

    //버튼 클릭 이벤트
    startButton->OnClicked.AddDynamic(this, &UTitleUserWidget::OnClickStartButton);
    exitButton->OnClicked.AddDynamic(this, &UTitleUserWidget::OnClickExitButton);
    
    //필요한 위젯만 사용하기
    UMyGameInstance* myGameInstance = Cast<UMyGameInstance>(GetGameInstance());
    EGameResult gameResult = myGameInstance->GetGameResult();
    //상단 텍스트
    titleText->SetVisibility(gameResult == EGameResult::None ? ESlateVisibility::Visible : ESlateVisibility::Hidden);
    gameOverText->SetVisibility(gameResult == EGameResult::GameOver ? ESlateVisibility::Visible : ESlateVisibility::Hidden);
    gameClearText->SetVisibility(gameResult == EGameResult::GameClear ? ESlateVisibility::Visible : ESlateVisibility::Hidden);
    //점수
    if (gameResult == EGameResult::None)
    {
       scoreBox->SetVisibility(ESlateVisibility::Hidden);
    }
    else
    {
       scoreBox->SetVisibility(ESlateVisibility::Visible);
       FString scoreString = FString::Printf(TEXT("%d"), myGameInstance->GetScore());
       scoreText->SetText(FText::FromString(scoreString));
    }
}

게임 시작, 게임 오버 외에 게임 클리어 상황을 만들었으니 이에 대하여 NativeConstruct 함수를 수정한다.

 

void UTitleUserWidget::OnClickStartButton()
{
    UMyGameInstance* myGameInstance = Cast<UMyGameInstance>(GetGameInstance());
    myGameInstance->GameStart();
}

게임 인스턴스에서 게임 시작 함수를 만들었으니 게임 시작 버튼도 함께 수정한다.

 

21. 나머지 할 일

A. 3스테이지 전용 아이템 스폰

public:
    int GetStage() const;

게임 인스턴스의 스테이지 번호는 Get함수가 필요없다고 했는데 아니었다. 그래도 Set 함수가 필요 없다는건 여전하다.

 

//3스테이지 한정 아이템 생성
UMyGameInstance* myGameInstance = Cast<UMyGameInstance>(GetGameInstance());
if (myGameInstance->GetStage() == 3)
{
    AItemManager::GetInstance()->SpawnItems(4, 1, false);
    AItemManager::GetInstance()->SpawnItems(5, 1, false);
    AItemManager::GetInstance()->SpawnItems(6, 1, false);
}

게임 스테이트의 BeginPlay함수 하단부에 3번째 맵인지 확인하고 맞다면 전용 아이템을 생성하도록 만든다.

 

private:
    //장애물 속도 증가
    const FString OBSTACLE_ACCELERATION = TEXT("장애물 속도 증가");
    //장애물 속도 감소
    const FString OBSTACLE_DECELERATION = TEXT("장애물 속도 감소");
    //장애물 회전 방향 반전
    const FString OBSTACLE_CHANGE_DIRECTION = TEXT("장애물 회전 방향 변경");

만들고 보니까 3번째 맵의 아이템에 메시지 출력이 누락된 것을 발견했다. 우선 메시지를 아이템 매니저 헤더에 만든다.

 

//스테이지3의 장애물 관련 아이템
case 4://속도 증가
case 5://속도 감소
case 6://방향 변경
    {
       ARotatingObstacle* rotatingObstacle = ARotatingObstacle::GetInstance();
       if (!::IsValid(rotatingObstacle))
          break;

       AMyPlayerController* controller = Cast<AMyPlayerController>(playerCharacter->GetController());
       if (controller == nullptr)
          return;

       if (itemID == 6)//방향 변경
       {
          rotatingObstacle->ChangeRotatingDirection();
          controller->GetUserWidget()->ShowBottomMessage(OBSTACLE_CHANGE_DIRECTION, FColor::White);
       }
       else//속도 증감
       {
          bool acceleration = itemID == 4;
          rotatingObstacle->AccelerateOrDecelerate(acceleration);
          controller->GetUserWidget()->ShowBottomMessage(acceleration ? OBSTACLE_ACCELERATION : OBSTACLE_DECELERATION, FColor::White);
       }
          
       //적용한 아이템 재생성
       SpawnItems(itemID, 1, false);
    }
break;

아이템 적용 부분에 메시지 출력을 만든다.

 

B. 첫 번째 웨이브 시작 알림이 안나오는 문제

이 문제는 유저 위젯이 완전히 추가되기 전에 접근해서 발생하는 문제임을 알았다. 만약 유저 위젯이 추가됨이 보장된다면 이 문제가 발생할 수가 없다.

 

public:
    //유저 위젯 추가가 완료된 직후에 실행하는 함수
    void AfterAddWidgetToViewport();

게임 스테이트에 함수 하나를 선언하고

 

//위젯
if (mainUserWidget != nullptr)
{
    mainUserWidget->AddToViewport();
    Cast<AMyGameState>(GetWorld()->GetGameState())->AfterAddWidgetToViewport();
}

플레이어 컨트롤러에서 유저 위젯 추가 함수 바로 밑에서 실행한다.

 

void AMyGameState::AfterAddWidgetToViewport()
{
    NextWave();

    //3스테이지 한정 아이템 생성
    UMyGameInstance* myGameInstance = Cast<UMyGameInstance>(GetGameInstance());
    if (myGameInstance->GetStage() == 3)
    {
       AItemManager::GetInstance()->SpawnItems(4, 1, false);
       AItemManager::GetInstance()->SpawnItems(5, 1, false);
       AItemManager::GetInstance()->SpawnItems(6, 1, false);
    }

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

기존에 게임 스테이트의 BeginPlay함수의 내용중 위젯에 접근하는 내용이 있는 다음 웨이브 시작 함수부터 새로 만든 함수로 이동한다. 이러면 유저 위젯 추가 후에 접근이 됨이 보장되니 문제가 발생하지 않는다.

 

C. 2번째 맵의 라이트 변화

현재 2번째 맵은 그냥 볼 때는 1번째 맵과 똑같이 보인다. 2번째 맵의 라이트를 변경하여 확실하게 다른 맵이라는 것을 보여준다.

Directional Light의 각도를 변경하고 강도를 조금 낮추니 저녁쯤 시간대의 모습처럼 변했다.

 

D. 더 확실한 버튼 색깔

지금은 타이틀 맵의 버튼에 마우스를 올려보면 조금 밝아지고 누르면 조금 어두워지는 것이 전부다. 이를 변경하여 더 확실하게 변하도록 만들었다.

일단 배경색을 흰색으로 돌려놓는다.

 

그리고 조금 위쪽에 노멀, 호버, 눌림 색상을 설정하는 곳이 있다. 사실 버튼의 색깔은 배경색에 여기서 지정한 색깔과 혼합해서 표시된다. 지금은 배경색이 완전 흰색이니 여기서 지정한 색깔이 온전히 표현된다.

기본적으로 노멀은 조금 밝은 회색이라서 제 아무리 배경을 흰색으로 하여도 약간 어둡게 표현되는 것이다. 호버는 흰색, 눌림은 노멀보다 어두운 회색이 기본 색상이다.

 

시작 버튼의 색깔을 정했으면 나가기 버튼의 색깔도 정한다.

 

왼쪽부터 차례대로 평상시, 마우스를 올린 상태, 눌린 상태의 시작버튼이다.