언리얼엔진 학습 및 과제

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

park94128 2025. 10. 17. 23:46

18. UI에 내용 추가하기

A. 제한 시간 표시

지금은 로그 아니면 시간이 얼마나 남았는지 알 수 없다. 그래서 로그 대신 화면에 출력하도록 변경한다.

위젯 블루프린트에서 다른 텍스트를 추가한 것처럼 위쪽 가운데에 텍스트를 추가한다. , 나머지 둘과 확실하게 구분하기 위해 크기를 조금 키웠으며 색깔을 변경했다.

UI의 크기 말고 텍스트의 크기는 디테일 화면의 폰트에서 변경이 가능하다.

 

public:
    //남은 시간
    void SetRemainingTime(int remainingTime);

private:
    //시간 표기용 텍스트
    UPROPERTY(EditAnywhere, meta = (AllowPrivateAccess = true, BindWidget))
    UTextBlock* timeTextBlock;

유저 위젯 클래스의 헤더에 다른 텍스트처럼 제한 시간용 함수 및 변수를 선언한다.

 

void UMainUserWidget::SetRemainingTime(int remainingTime)
{
    if (timeTextBlock == nullptr)
       return;

    FString remainingString = FString::Printf(TEXT("Time : %d"), remainingTime);
    timeTextBlock->SetText(FText::FromString(remainingString));

    //남은 시간 10초 기준으로 색깔 변경
    if (remainingTime <= 10)
       timeTextBlock->SetColorAndOpacity(FColor::Red);
    else
       timeTextBlock->SetColorAndOpacity(FColor::Green);
}

다른 텍스트 처럼 함수 내용을 구현하되 여기서는 변화를 준다. 남은 시간이 10초 이하일 경우 빨간색으로, 아니면 초록색으로 표기하도록 변경하는 기능을 구현했다.

 

//UI에 반영
UMainUserWidget* widget = playerController->GetUserWidget();
widget->SetRemainingTime(remainingTime);

게임 스테이트에서 다음 웨이브로 넘어가는 함수와 시간을 세는 함수에 구현한 함수를 적용한다.

 

B. 웨이브 알림

이번에는 웨이브 알림을 구현한다. 지금은 왼쪽 위 구석에만 표기되기 때문에 웨이브가 넘어갔는지 바로 인지하기가 힘들다. 그래서 화면 가운데에 알림이 나오도록 한다.

이번에는 보더 대신 세로 박스(Vertical Box)를 사용해보자. 보더처럼 자식을 가질 수 있지만 보더와는 달리 여러 자식을 가질 수 있다. 모든 자식은 자동으로 세로 정렬된다.

2개의 텍스트를 넣어 2줄의 텍스트를 출력한다.

 

화면 가운데에 출력할 테니 박스에 검은 배경을 넣기에는 조금 곤란하다. 그 대신 폰트 설정 안에 있는 윤곽선 세팅이 있기 때문에 문제는 없을 것이다. 글씨에 외곽선을 적용해도 충분히 잘 보일것이다.

 

동일한 방법으로 알림용 위젯 2개 더 만든다. 모두 웨이브 시작시 적절한 것이 나오도록 만들 것이다.

 

3개의 위젯을 같은 곳이 놔둔다. 그리고 세로 박스의 디테일 화면에서 렌더 오파시티를 0으로 하면 완전히 투명해지니 화면에서 보이지 않게 된다. 3개의 세로 박스 모두에게 적용한다.

 

이제 화면 하단에서 애니메이션 패널을 연다. 그리고 새로운 애니메이션을 추가한다. 이 애니메이션 이름은 C++ 코드에서 변수 이름이 될 테니 이를 생각하고 이름을 명명한다.

 

애니메이션 내부에 만들었던 알림 위젯을 추가한다. 그리고 추가한 위젯에서 렌더 오파시티를 추가한다.

 

내가 구상한 연출은 0.5초동안 나타났다가 1초동안 보여주고 0.5초동안 사라지는 것이다. 2초 걸리는 연출이다.

 

3개의 알림 위젯 모두에 대하여 애니메이션을 만든다.

 

private:;
    //일반 웨이브 알림
    UPROPERTY(EditAnywhere, meta = (AllowPrivateAccess = true, BindWidgetAnim))
    UWidgetAnimation* normalWaveAnimation;

    //해골 웨이브 알림
    UPROPERTY(EditAnywhere, meta = (AllowPrivateAccess = true, BindWidgetAnim))
    UWidgetAnimation* skullWaveAnimation;

    //얼음 웨이브 알림
    UPROPERTY(EditAnywhere, meta = (AllowPrivateAccess = true, BindWidgetAnim))
    UWidgetAnimation* iceWaveAnimation;

C++ 코드에서 3개의 애니메이션에 대한 변수를 만든다. 변수 타입은 UWidgetAnimation이다. 애니메이션을 바인딩할 경우 UPROPERTYBindWidget이 아니라 BindWidgetAnim을 써야 한다.

 

//웨이브 종류
enum class EWaveType
{
    Normal,
    Skull,
    Ice
};

이번에는 Enum을 사용한다. 클래스 위쪽에 만든다.

 

public:
    //웨이브 알림
    void WaveAlert(EWaveType wave);

클래스 안쪽에는 웨이브 알림 애니메이션을 실행할 함수를 정의한다.

 

void UMainUserWidget::WaveAlert(EWaveType wave)
{
    //주어진 값에 맞는 애니메이션을 실행한다.
    switch (wave)
    {
    case EWaveType::Normal:
       PlayAnimation(normalWaveAnimation);
       break;

    case EWaveType::Skull:
       PlayAnimation(skullWaveAnimation);
       break;

    case EWaveType::Ice:
       PlayAnimation(iceWaveAnimation);
       break;

    default:
       break;
    }
}

UWidgetAnimation을 실행하는 함수는 PlayAnimation이다. 이 함수를 사용하여 구현한다.

 

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

//매 반복마다 2번째 웨이브에서 해골, 구급약 아이템 생성
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);

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

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

게임 스테이트의 다음 웨이브로 넘어가는 함수에서 웨이브 알림 함수를 호출하도록 만든다.

 

그런데 코드를 컴파일 해보니 위젯 블루프린트에서 컴파일이 안된다. 임시가 아니라고?

 

애니메이션 관련 변수를 바인딩 할 경우 UPROPERTYTransient를 적어야 한다. 이것은 게임 데이터를 로컬에 저장할 경우 해당 변수의 값을 저장하지 않겠다는 의미인데 바인딩된 애니메이션 변수에 대해선 이 속성이 필수라고 한다.

 

애니메이션이 잘 작동된다. 그런데 제일 처음 시작시 알림이 나오지 않는다. 아마 BeginPlay에서 위젯에 접근하여 발생하는 문제 때문일 것이다. 이 부분은 차후에 수정해야 겠다.

 

C. 디버프 등 효과 적용 알림

이번에는 보더와 텍스트를 화면 하단에 배치한다. 이번에는 화면 영역 밖에 놔둔다. 텍스트의 이름은 코드의 변수와 동일하게 만들 것이다.

 

텍스트의 디테일 화면 중 패딩을 설정한다. 이 설정을 하면 글자가 차지하는 부분 외에도 추가로 지정한 값만큼 공간을 가진다.

 

보더의 크기 설정 중 콘텐츠 크기에 맞춤을 사용하면 자식의 크기에 맞게 보더의 크기가 자동으로 변경된다.

 

이 위젯을 화면 밖에 놔둔 이유는 메시지가 생기면 하단에서 위로 올라왔다가 도로 내려가는 연출을 만들기 위함이다. 0.25초 동안 올라오고 1초동안 머물다 0.25초 동안 내려간다.

 

private:
    //하단 메세지 텍스트
    UPROPERTY(EditAnywhere, meta = (AllowPrivateAccess = true, BindWidget))
    UTextBlock* messageTextBlock;


    //하단 메세지 알림
    UPROPERTY(EditAnywhere, Transient, meta = (AllowPrivateAccess = true, BindWidgetAnim))
    UWidgetAnimation* bottomMessage;

다 만들었으면 이제 코드 작업이다. 먼저 위젯과 연결되는 변수를 만든다.

 

public:
    //하단 메세지 출력
    void ShowBottomMessage(FString message);

하단 메시지를 받고 출력해주는 함수도 선언한다. 이때 메시지 내용과 텍스트 색깔을 매개변수로 받는다.

 

void UMainUserWidget::ShowBottomMessage(FString message, FColor textColor)
{
    if (bottomMessage == nullptr)
       return;

    //텍스트 블록
    messageTextBlock->SetText(FText::FromString(message));
    messageTextBlock->SetColorAndOpacity(textColor);

    //연출
    PlayAnimation(bottomMessage);
}

받은 메시지와 색깔을 텍스트 블록에 적용하고 애니메이션을 실행한다.

 

//ItemManager.h
private:
    //속도 감소 메세지
    const FString SPEED_DEBUFF_MSG = TEXT("이동 속도 감소");
    //이동 반전 메세지
    const FString REVERSE_MOVING_MSG = TEXT("이동 방향 반전");
    //회복 메세지
    const FString RESTORE_MSG = TEXT("디버프 해제");

//ItemManager.cpp
case 2://해골
    {
       FString message;
       if (FMath::RandRange(0, 1) == 0)
       {
          //이동 속도 감소
          playerCharacter->SpeedDebuff();
          message = SPEED_DEBUFF_MSG;
       }
       else
       {
          //이동 반전
          playerCharacter->ReverseMovingDebuff();
          message = REVERSE_MOVING_MSG;
       }
       
       //메세지
       if (AMyPlayerController* controller = Cast<AMyPlayerController>(playerCharacter->GetController()); controller != nullptr)
          controller->GetUserWidget()->ShowBottomMessage(message, FColor::Purple);
    }
    break;
    
case 3://구급약
    {
       playerCharacter->RestoreMovement();

       //메세지
       if (AMyPlayerController* controller = Cast<AMyPlayerController>(playerCharacter->GetController()); controller != nullptr)
          controller->GetUserWidget()->ShowBottomMessage(RESTORE_MSG, FColor::Green);
    }
    break;

아이템 매니저에서 해골 아이템과 구급약 아이템 획득시 메시지를 출력하게 만든다.

 

 

 

19. 타이틀 맵

게임 처음 시작시, 그리고 게임 오버시 진입할 맵을 만든다.

 

A. 맵 생성 및 위젯 블루프린트 배치

새로운 맵(레벨)을 생성한다. 이번에는 콘텐츠 브라우저에서 바로 생성한다

 

이렇게 콘텐츠 브라우저에서 생성한 경우 무조건 아무것도 없는 빈 맵만 만들 수 있다. 하지만 여기서는 다른 조작을 할 필요도 없이 시작, 종료 버튼만 필요하기 때문에 UI를 제외하면 조명이나 메쉬 등 액터가 필요없다.

게임에서 사용하는 UI를 만든 것 처럼 똑같이 새로운 C++ 클래스 및 위젯 블루프린트를 만든다.

 

복잡한 게임 로직이 필요없는 맵이니 코드 없이 바로 블루프린트로 게임 모드와 플레이어 컨트롤러를 생성한다.

 

게임 모드에서는 같이 생성한 플레이어 컨트롤러를 지정하기만 하면 된다.

 

플레이어 컨트롤러에서 변수를 생성한다. 변수 타입은 코드로 생성한 유저 위젯 클래스(오브젝트 레퍼런스)로 지정한다.

 

변수에 대한 디테일 패널에는 기본값 부분이 있는데 이를 생성했던 위젯 블루프린트로 지정한다.

 

위젯 블루프린트에서는 버튼(Button)을 배치한다. 2개를 하단에 적절히 배치한다. 처음 배치시 크기는 좀 작으니 크기도 변경한다.

버튼은 코드와 연동할 것임을 감안하여 이름을 짓는다.

 

코드 컴파일, 블루프린트 컴파일 및 저장을 모두 마치고 실행하여 버튼이 잘 나오는지 확인한다.

 

B. 버튼 외형 완성하기

왼쪽 버튼을 시작 버튼으로, 오른쪽 버튼을 종료하기 버튼으로 만들 생각이다. 그래서 왼쪽을 밝은 파란색으로, 오른쪽을 빨간색으로 변경했다.

버튼 안에 텍스트를 넣어 어떤 버튼인지 알 수 있도록 만든다.

 

버튼의 디테일 패널에는 툴팁 텍스트라는 것이 있는데 여기에 내용을 입력하면 버튼에 커서를 올릴 때 입력한 내용이 출력된다.

 

C. 버튼 기능 만들기

private:
    //시작 버튼
    UPROPERTY(EditAnywhere, meta = (allowPrivateAccess = true, BindWidget))
    class UButton* startButton;

    //종료 버튼
    UPROPERTY(EditAnywhere, meta = (allowPrivateAccess = true, BindWidget))
    UButton* exitButton;

코드와 위젯을 연동하기 위해 UButton타입 변수를 선언한다.

 

protected:
    virtual void NativeConstruct() override;

private:
    //시작 버튼 클릭
    UFUNCTION()
    void OnClickStartButton();
    //종료 버튼 클릭
    UFUNCTION()
    void OnClickExitButton();

위젯 클래스의 시작인 NativeConstruct함수와 각 버튼을 누를 때 실행할 함수를 선언한다. 버튼 클릭 이벤트와 연동할 함수는 UFUNCTION()을 적어야 한다.

 

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

    //버튼 클릭 이벤트
    startButton->OnClicked.AddDynamic(this, &UTitleUserWidget::OnClickStartButton);
    exitButton->OnClicked.AddDynamic(this, &UTitleUserWidget::OnClickExitButton);
}

NativeConstruct에서 각 버튼마다 클릭시 실행할 함수를 지정해준다.

 

void UTitleUserWidget::OnClickStartButton()
{
    UGameplayStatics::OpenLevel (GetWorld(), TEXT("Main"));
}

void UTitleUserWidget::OnClickExitButton()
{
    UKismetSystemLibrary::QuitGame(this, nullptr, EQuitPreference::Quit, false);
}

시작버튼을 누르면 게임 맵으로 이동하도록 한다. 다른 맵으로 이동하는 함수는 UGameplayStatics에 있는 OpenLevel 함수다.

UKismetSystemLibrary에 있는 QuitGame함수는 게임을 종료하는 함수다. 게임 종료 버튼으로 실행할 함수에 이 함수를 사용한다.

 

이제 프로젝트 세팅 화면을 열고 & 모드를 누른다.

에디터 시작 맵과 게임 기본 맵을 각각 타이틀 맵으로 지정한다. 사실 전자는 에디터를 열 때 시작할 맵이니 필수는 아니다. 만약 게임을 빌드하여 게임 프로그램을 내놓을 경우 후자를 따라가니 이는 중요한 사항이다.

 

D. 게임 오버시 타이틀 맵 이동

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

    //1초마다 시간 감소 적용
    if (timeCounting >= 1.0f)
    {
       timeCounting -= 1.0f;
       remainingTime--;

       //UI에 반영
       UMainUserWidget* widget = playerController->GetUserWidget();
       widget->SetRemainingTime(remainingTime);
       
       //0초 도달시 게임 종료
       if (remainingTime == 0)
          UGameplayStatics::OpenLevel(GetWorld(), TEXT("Title"));
    }
}

게임 스테이트에서 제한 시간이 0이 될 경우 그냥 로그를 출력하는 것으로 퉁쳤는데 이를 고쳐서 처음 맵으로 돌아가게 만든다.

 

작동은 잘 되는데 처음 시작할 때나 게임 오버로 돌아올 때나 같은 화면이라서 어색하다.

 

처음 시작시 이렇게 텍스트 하나가 보이도록 하고

 

게임 오버일 경우 이렇게 점수를 포함한 텍스트 세트를 보여주는 것이 좋을 것 같다.

 

private:
    //게임 타이틀 텍스트
    UPROPERTY(EditAnywhere, meta = (allowPrivateAccess = true, BindWidget))
    UWidget* titleText;

    //게임 오버 텍스트
    UPROPERTY(EditAnywhere, meta = (allowPrivateAccess = true, BindWidget))
    UWidget* gameOverText;

    //점수 텍스트가 있는 세로 박스
    UPROPERTY(EditAnywhere, meta = (allowPrivateAccess = true, BindWidget))
    UWidget* scoreBox;

    //점수 텍스트
    UPROPERTY(EditAnywhere, meta = (allowPrivateAccess = true, BindWidget))
    class UTextBlock* scoreText;

사전 작업으로 각 위젯을 C++ 변수와 연동한다. 점수 텍스트 말고는 활성화 여부만 사용할 것이니 그냥 UWidget타입으로 지정한다.

 

E. 게임 인스턴스

게임 인스턴스는 게임 전체에서 다룰 수 있는 데이터를 저장할 수 있다. 어느 한 맵에 종속되지 않는 존재라서 게임이 시작될 때부터 항상 존재하며 게임 자체가 종료되지 않는 한 영원히 사라지지 않는다.

 

C++ 코드 생성시 GameInstance를 선택하여 생성한다.

 

생성했다면 코드를 열기 전에 프로젝트 세팅을 열어서 시작 맵을 지정했던 곳을 다시 찾는다. 그 아래에 보면 게임 인스턴스 클래스를 지정하는 부분이 있는데 금방 생성한 게임 인스턴스를 지정한다.

 

class NBC_TASK8_API UMyGameInstance : public UGameInstance
{
    GENERATED_BODY()

public:
    virtual void Init() override;
    
    int GetScore() const;
    void SetScore(int value);

    bool GetGameOver() const;
    void SetGameOver(bool value);

private:
    //점수
    int score;
    //게임 오브 여부
    bool gameOver;
};

헤더의 구성은 이렇다.

- 게임 인스턴스는 맵에 있는 액터가 아니라서 BeginPlay 함수가 없으며 Init 함수가 대신한다.

- 전역으로 저장할 값은 점수와 게임 오버 여부 2개뿐이다. 2개에 대한 Get, Set 함수를 각각 선언했다.

 

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

    score = 0;
    gameOver = false;
}

Get, Set 함수의 내용은 뻔하니까 생략한다. Init함수에서는 두 함수의 초기화 한다.

 

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

    //버튼 클릭 이벤트
    startButton->OnClicked.AddDynamic(this, &UTitleUserWidget::OnClickStartButton);
    exitButton->OnClicked.AddDynamic(this, &UTitleUserWidget::OnClickExitButton);
    
    //필요한 위젯만 사용하기
    UMyGameInstance* myGameInstance = Cast<UMyGameInstance>(GetGameInstance());
    if (myGameInstance->GetGameOver())
    {
       titleText->SetVisibility(ESlateVisibility::Hidden);
       gameOverText->SetVisibility(ESlateVisibility::Visible);
       scoreBox->SetVisibility(ESlateVisibility::Visible);
       
       //게임 오버시 점수 적용
       FString scoreString = FString::Printf(TEXT("%d"), myGameInstance->GetScore());
       scoreText->SetText(FText::FromString(scoreString));
    }
    else
    {
       titleText->SetVisibility(ESlateVisibility::Visible);
       gameOverText->SetVisibility(ESlateVisibility::Hidden);
       scoreBox->SetVisibility(ESlateVisibility::Hidden);
    }
}

이제 타이틀 맵의 유저 위젯을 수정한다. NativeConstruct함수에서 게임 인스턴스를 가져온다. 처음 시작할 때는 gameOver의 값이 false일 것이고 아니라면 true일 것이 이를 이용하여 사용할 위젯을 정한다.

게임 오버시 점수도 가져와서 화면에 출력하도록 한다.

 

void UTitleUserWidget::OnClickStartButton()
{
    Cast<UMyGameInstance>(GetGameInstance())->SetScore(0);
    UGameplayStatics::OpenLevel(GetWorld(), TEXT("Main"));
}

게임 시작 버튼을 누를 때마다 점수 초기화를 한다.

 

//0초 도달시 게임 종료
if (remainingTime == 0)
{
    UMyGameInstance* myGameInstance = Cast<UMyGameInstance>(GetGameInstance());
    myGameInstance->SetScore(score);
    myGameInstance->SetGameOver(true);
    
    UGameplayStatics::OpenLevel(GetWorld(), TEXT("Title"));
}

게임 스테이트 코드 중 게임 오버시 작동하는 구간에는 게임 인스턴스에 값을 적용하는 기능을 추가한다.

 

원하는대로 작동하긴 하는데, 게임하다 넘어오면 마우스 커서가 안보이는 문제가 있다.

 

F. 마우스 숨기기 및 보여주기

마우스 커서 문제는 생각보다 간단했다. 그냥 플레이어 컨트롤러에서 SetShowMouseCursor 함수를 호출하면 바로 해결된다.