GameStudy 2023. 8. 31. 23:05

10.1 Head-Up Display

10.1-1 HUD

  - HUD

    게임 플레이 동안에 플레이어에게 정보(HP, MP, Minimap, ...)를 보여주는 UI

 

  - 캐릭터 스탯 UI

    새 C++ 클래스 > UserWidget 부모 클래스 > "SCharacterStatWidget"

    새 UserInterface 애셋 > Widget Blueprint > SCharacterStatWidget 부모 클래스 > "WBP_CharacterStatWidget"

    아래 그림과 같이 Hierachy 구성.

    [수정필요. Bind 후에 바로 Update를 한 번 해주는 코드가 없음.]

<hide/>

// SCharacterStatWidget.h

#pragma once

#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "SCharacterStatWidget.generated.h"

UCLASS()
class STUDYPROJECT_API USCharacterStatWidget : public UUserWidget
{
    GENERATED_BODY()

public:
    USCharacterStatWidget(const FObjectInitializer& ObjectInitializer = FObjectInitializer::Get());

    void BindCharacterStatComponent(class USStatComponent* InCharacterStatComponent);
    
    void BindPlayerState(class ASPlayerState* InPlayerState);

protected:
    virtual void NativeConstruct() override;
    
    void UpdateHPBar(float InOldHP, float InNewHP);
    
    void UpdateEXPBar(float InOldEXP, float InNewEXP);

    void UpdateLevelText(int32 InOldCurrentLevel, int32 InNewCurrentLevel);

private:
    TWeakObjectPtr<class USStatComponent> CurrentStatComponent;
    
    TWeakObjectPtr<class ASPlayerState> CurrentPlayerState;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = USCharacterStatWidget, meta = (AllowPrivateAccess = true, BindWidget))
    TObjectPtr<class UProgressBar> HPBar;
    
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = USCharacterStatWidget, meta = (AllowPrivateAccess = true, BindWidget))
    TObjectPtr<class UProgressBar> EXPBar;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = USCharacterStatWidget, meta = (AllowPrivateAccess = true, BindWidget))
    TObjectPtr<class UTextBlock> PlayerLevel;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = USCharacterStatWidget, meta = (AllowPrivateAccess = true, BindWidget))
    TObjectPtr<class UTextBlock> PlayerName;

};
<hide/>

// SCharacterStatWidget.cpp

#include "SCharacterStatWidget.h"
#include "Components/ProgressBar.h"
#include "Components/TextBlock.h"
#include "SStatComponent.h"
#include "SPlayerState.h"

USCharacterStatWidget::USCharacterStatWidget(const FObjectInitializer& ObjectInitializer)
    : Super(ObjectInitializer)
{
}

void USCharacterStatWidget::BindCharacterStatComponent(USStatComponent* InCharacterStatComponent)
{
    if (nullptr != InCharacterStatComponent)
    {
        if (nullptr != CurrentStatComponent)
        {
            if (true == CurrentStatComponent->OnCurrentHPChangedDelegate.IsAlreadyBound(this, &ThisClass::UpdateHPBar))
            {
                CurrentStatComponent->OnCurrentHPChangedDelegate.RemoveDynamic(this, &ThisClass::UpdateHPBar);
            }

            CurrentStatComponent = InCharacterStatComponent;

            if (false == CurrentStatComponent->OnCurrentHPChangedDelegate.IsAlreadyBound(this, &ThisClass::UpdateHPBar))
            {
                CurrentStatComponent->OnCurrentHPChangedDelegate.AddDynamic(this, &ThisClass::UpdateHPBar);
            }
        }
    }
}

void USCharacterStatWidget::BindPlayerState(ASPlayerState* InPlayerState)
{
    if (nullptr != InPlayerState)
    {
        if (nullptr != CurrentPlayerState)
        {
            if (true == CurrentPlayerState->OnCurrentLevelChangedDelegate.IsAlreadyBound(this, &ThisClass::UpdateLevelText))
            {
                CurrentPlayerState->OnCurrentLevelChangedDelegate.RemoveDynamic(this, &ThisClass::UpdateLevelText);
                CurrentPlayerState->OnCurrentEXPChangedDelegate.RemoveDynamic(this, &ThisClass::UpdateEXPBar);
            }

            CurrentPlayerState = InPlayerState;

            if (false == CurrentPlayerState->OnCurrentLevelChangedDelegate.IsAlreadyBound(this, &ThisClass::UpdateLevelText))
            {
                CurrentPlayerState->OnCurrentLevelChangedDelegate.AddDynamic(this, &ThisClass::UpdateLevelText);
                CurrentPlayerState->OnCurrentEXPChangedDelegate.AddDynamic(this, &ThisClass::UpdateEXPBar);
            }
        }
    }
}

void USCharacterStatWidget::NativeConstruct()
{
}

void USCharacterStatWidget::UpdateHPBar(float InOldHP, float InNewHP)
{
    if (true == CurrentStatComponent.IsValid() && KINDA_SMALL_NUMBER < CurrentStatComponent->GetMaxHP())
    {
        HPBar->SetPercent((CurrentStatComponent->GetCurrentHP() / CurrentStatComponent->GetMaxHP()) * 100.f);
    }
}

void USCharacterStatWidget::UpdateEXPBar(float InOldEXP, float InNewEXP)
{
    if (true == CurrentPlayerState.IsValid() && KINDA_SMALL_NUMBER < CurrentPlayerState->GetMaxEXP())
    {
        EXPBar->SetPercent((CurrentPlayerState->GetCurrentEXP() / CurrentPlayerState->GetMaxEXP()) * 100.f);
    }
}

void USCharacterStatWidget::UpdateLevelText(int32 InOldCurrentLevel, int32 InNewCurrentLevel)
{
    if (true == CurrentPlayerState.IsValid())
    {
        FString PlayerLevelString = FString::Printf(TEXT("%d"), CurrentPlayerState->GetCurrentLevel());
        PlayerLevel->SetText(FText::FromString(PlayerLevelString));
        PlayerName->SetText(FText::FromString(CurrentPlayerState->GetPlayerName()));
    }
}

 

  - 스탯 위젯 띄우기

    SPlayerController 클래스를 아래와 같이 수정 후 컴파일.

    BP_PlayerController > Character Stat Widget Class에 WBP_CharacterStatWidget 지정.

<hide/>

// SPlayerController.h

...
class STUDYPROJECT_API ASPlayerController : public APlayerController
{
    ...

public:
    ...

    class USCharacterStatWidget* GetStatWidget() const { return CharacterStatWidget; }

protected:
    ...

    virtual void OnPossess(APawn* aPawn) override;

private:
    ...

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = ASPlayerCharacter, meta = (AllowPrivateAccess = true))
    TSubclassOf<class USCharacterStatWidget> CharacterStatWidgetClass;

    UPROPERTY()
    TObjectPtr<class USCharacterStatWidget> CharacterStatWidget;

};
<hide/>

// SPlayerController.cpp

#include "SPlayerController.h"
#include "SGamePauseWidget.h"
#include "SCharacterStatWidget.h"
#include "SPlayerCharacter.h"

...

void ASPlayerController::OnPossess(APawn* aPawn)
{
    Super::OnPossess(aPawn);

    if (nullptr != aPawn)
    {
        ASPlayerCharacter* PlayerCharacter = Cast<ASPlayerCharacter>(aPawn);

        if (nullptr != CharacterStatWidgetClass)
        {
            CharacterStatWidget = CreateWidget<USCharacterStatWidget>(this, CharacterStatWidgetClass);
            if (nullptr != CharacterStatWidget)
            {
                CharacterStatWidget->AddToViewport(3);

                CharacterStatWidget->BindCharacterStatComponent(PlayerCharacter->GetStatComponent());
                CharacterStatWidget->BindPlayerState(GetPlayerState<ASPlayerState>());
            }
        }
    }
}

    

  - LastHitBy 속성을 이용한 경험치 구현

<hide/>

// SStatComponent.cpp

...
#include "SPlayerCharacter.h"
#include "SPlayerState.h"
#include "SPlayerController.h"

...

void USStatComponent::SetCurrentHP(float InCurrentHP)
{
    ...

    if (CurrentHP < KINDA_SMALL_NUMBER)
    {
        OnOutOfCurrentHPDelegate.Broadcast();

        if (ASPlayerCharacter* OwnerPlayerCharacter = Cast<ASPlayerCharacter>(GetOwner()))
        {
            if (ASPlayerController* LastHitPC = Cast<ASPlayerController>(OwnerPlayerCharacter->LastHitBy))
            {
                if (ASPlayerState* LastHitPS = LastHitPC->GetPlayerState<ASPlayerState>())
                {
                    LastHitPS->SetCurrentEXP(LastHitPS->GetCurrentEXP() + 50.f);
                }
            }
        }

        CurrentHP = 0.f;
    }
}

...

 

 

 

 

10.2 UI Level

10.2-1 Title Level

  - Title Level 제작

    Toolbar > File > New Level > Empty Level

    Ctrl + S > Levels 폴더 선택 후 "Title"

    해당 레벨은 아무 기능 없이 UI 화면만 띄우는 역할.

    Toolbar > Settings > World Settings > Maps & Modes

    Editor Startup Map에 Title 지정.

    해당 레벨에서 사용할 게임 모드와 UI를 띄울 플레이어 컨트롤러를 제작해야함.

 

  - Title Level 전용 GameMode

    Content Browser > Game > 새 블루프린트 애셋 > GameModeBase 부모 클래스

    "BP_TitleGameMode" 생성 후 더블클릭.

    Details > Classes > Defualt Pawn Class는 None 지정

    Toolbar > Settings > World Settings > GameMode Override에 BP_TitleGameMode 지정.

 

  - UI만 있는 레벨에서 사용할 컨트롤러

    새 C++ 클래스 > PlayerController 부모 클래스 > "SUIPlayerController"

    Path > Controllers

    새로운 플레이어 컨트롤러 클래스를 생성하면 이를 상속 받는 블루프린트에서

    앞으로 띄울 UI의 클래스 값을 에디터에서 설정할 수 있도록 위젯 클래스 속성을 

    추가하고 EditDefaultOnly 키워드를 지정함.

    플레이어 컨트롤러의 로직은 게임을 시작하면 해당 클래스로부터 UI 개체를 생성하고

    이를 뷰포트에 띄운 후에 입력은 UI로만 전달되도록 제작함.

<hide/>

// SUIPlayerController.h

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/PlayerController.h"
#include "SUIPlayerController.generated.h"

/**
 * 
 */
UCLASS()
class STUDYPROJECT_API ASUIPlayerController : public APlayerController
{
    GENERATED_BODY()

public:
    virtual void BeginPlay() override;

private:
    UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = ASUIPlayerController, Meta = (AllowPrivateAccess))
    TSubclassOf<class UUserWidget> UIWidgetClass;

    UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = ASUIPlayerController, Meta = (AllowPrivateAccess))
    TObjectPtr<class UUserWidget> UIWidgetInstance;
    
};
<hide/>

// SUIPlayerController.cpp


#include "Controllers/SUIPlayerController.h"
#include "Blueprint/UserWidget.h"

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

    if (nullptr != UIWidgetClass)
    {
        UIWidgetInstance = CreateWidget<UUserWidget>(this, UIWidgetClass); // CreateWidget()이 호출될 때 UIWidgetInstance->NativeOnInitialize() 함수가 호출됨.
        if (nullptr != UIWidgetInstance)
        {
            UIWidgetInstance->AddToViewport();                             // AddToViewport()가 호출 될 때 UIWidgetInstance->NativeConstruct() 함수가 호출됨.

            FInputModeUIOnly Mode;
            Mode.SetWidgetToFocus(UIWidgetInstance->GetCachedWidget());
            SetInputMode(Mode);

            bShowMouseCursor = true;
        }
    }
}

 

  - Title 전용 PlayerController

    Content Browser > Controllers > 새 블루프린트 애셋 > SUIPlayerController 부모 클래스

    "BP_TitlePlayerController" 생성.

    BP_TitleGameMode > Details > Player Controller Class에 BP_TitlePlayerController 지정.

 

  - Title 전용 UI 클래스 생성

    새 C++ 클래스 > UserWidget 부모클래스 > "STitleWidget"

    Path > UI

    아래와 같이 작성 후 컴파일.

<hide/>

// STitleWidget.h

#pragma once

#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "STitleWidget.generated.h"

/**
 * 
 */
UCLASS()
class STUDYPROJECT_API USTitleWidget : public UUserWidget
{
    GENERATED_BODY()

public:
    USTitleWidget(const FObjectInitializer& ObjectInitializer = FObjectInitializer::Get());

protected:
    virtual void NativeConstruct() override;

private:
    UFUNCTION()
    void OnNewGameButtonClicked();

    UFUNCTION()
    void OnExitGameButtonClicked();

private:
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = USTitleWidget, Meta = (AllowPrivateAccess, BindWidget)) // BindWidget을 사용하면 자동으로 NewGameButton 속성과 NewGameButton 이름을 가진 위젯이 바인드됨.
    TObjectPtr<class UButton> NewGameButton;
    
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = USTitleWidget, Meta = (AllowPrivateAccess, BindWidget))
    TObjectPtr<class UButton> ExitGameButton;
    
};
<hide/>

// STitleWidget.cpp


#include "UI/STitleWidget.h"
#include "Components/Button.h"
#include "Kismet/KismetSystemLibrary.h"

USTitleWidget::USTitleWidget(const FObjectInitializer& ObjectInitializer)
    : Super(ObjectInitializer)
{
}

void USTitleWidget::NativeConstruct()
{
    NewGameButton.Get()->OnClicked.AddDynamic(this, &ThisClass::OnNewGameButtonClicked);
    ExitGameButton.Get()->OnClicked.AddDynamic(this, &ThisClass::OnExitGameButtonClicked);
}

void USTitleWidget::OnNewGameButtonClicked()
{
    UKismetSystemLibrary::PrintString(this, FString::Printf(TEXT("OnNewGameButtonClicked() has been called.")));
}

void USTitleWidget::OnExitGameButtonClicked()
{
    UKismetSystemLibrary::QuitGame(this, GetOwningPlayer(), EQuitPreference::Quit, false);
}

 

  - Title 전용 UI 제작

    Content Browser > Study Project > UI

    새 User Interface 애셋 > WidgetBlueprint > STitleWidget 부모 클래스 > "WBP_Title"

    생성 초기에 Compile Erorr 나는 것을 볼 수 있음. NewGameButton과 ExitGameButton 이름을 가진 위젯이 없기 때문.

    Palette > Canvas Panel을 Hierarchy의 WBP_Title에 드랍.

    Palette > Text를 Canvas Panel에 드래그 드랍. 이 Text Widget에 게임 이름을 적음.

    Palette > Vertical Box를 Canvas Panel에 드래그 드랍.

    Palette > Spacer를 Vertical Box에 3개 드래그 드랍.

    Palette > Text를 Vertical Box의 Spacer 사이사이 배치.

    Text 위젯들 모두 블럭지정 > Wrap By > Button 클릭.

    Vertical Box 클릭 > Details > Size to Contents 체크. 그럼 내용물로 들어있는 위젯들 크기로 맞춰짐.

 

  - Title 전용 UI 설정

    Content Browser > BP_TitlePlayerControler > Details > UI Widget Class에 WBP_Title 지정.

    플레이 후 테스트.

 

  - 고화질 스크린샷 

    지금의 Title 레벨은 너무 허전함. 인게임 스크린샷을 찍어서 배경으로 사용해보고자함.

    플레이 > F8로 빙의 탈출 > 원하는 각도로 이동.

    F11 > 좌상단 세 줄 기호 클릭 > High Resolution Screenshot 클릭 > Capture 누른 후 팝업창에 링크 바로 클릭.

    해당 스크린샷 클릭 > F2 > "T_TitleImage"로 이름 변경. 프로젝트 폴더로 이동시킴.

    언리얼 에디터 > Content Browser 우클릭 > 새 폴더 "Textures"

    Textures 우클릭 > Import to /Game/... 클릭 > T_TitleImage 임포트.

    안되면 T_TitleImage를 프로젝트 폴더 > Content > Textures 폴더에 이동.

 

  - WBP_Title 수정

    Palete > Scale Box를 Canvas에 드래그 드랍.

    Scale Box > Details > Anchors 클릭 > 마지막 앵커 클릭해서 전체로 잡음.

    Stretch에 Scale to Fill로 지정.

    Offset 값들을 모두 0으로 지정해서 확대.

    Palete > Image를 Scale Box에 드래그 드랍.

    Image > Details > Brush > Image에 T_TitleImage 지정.

 

  - Saved Game 기능 구현

    TitleWidget 클래스 수정. 아래와 같이 작성 후 컴파일.

    Title UI에 Saved Game 버튼 추가. 아래 그림 참고.

<hide/>

// TitleWidget.h

...
class STUDYPROJECT_API UTitleWidget : public UUserWidget
{
    ...

private:
    UFUNCTION()
    void OnNewGameButtonClicked();

    UFUNCTION()
    void OnSavedGameButtonClicked();

    UFUNCTION()
    void OnExitGameButtonClicked();

private:
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = UTitleWidget, meta = (AllowPrivateAccess = true, BindWidget))
    TObjectPtr<class UButton> NewGameButton;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = UTitleWidget, meta = (AllowPrivateAccess = true, BindWidget))
    TObjectPtr<class UButton> SavedGameButton;
    
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = UTitleWidget, meta = (AllowPrivateAccess = true, BindWidget))
    TObjectPtr<class UButton> ExitGameButton;

};
<hide/>

// TitleWidget.cpp

...
#include "SPlayerState.h"
#include "SPlayerCharacterSaveGame.h"

...

void UTitleWidget::NativeConstruct()
{
    NewGameButton.Get()->OnClicked.AddDynamic(this, &ThisClass::OnNewGameButtonClicked);
    SavedGameButton.Get()->OnClicked.AddDynamic(this, &ThisClass::OnSavedGameButtonClicked);
    ExitGameButton.Get()->OnClicked.AddDynamic(this, &ThisClass::OnExitGameButtonClicked);

    SavedGameButton->SetIsEnabled(false);
    if (const ASPlayerState* PS = GetDefault<ASPlayerState>())
    {
        if (USPlayerCharacterSaveGame* PCSG = Cast<USPlayerCharacterSaveGame>(UGameplayStatics::LoadGameFromSlot(PS->SaveSlotName, 0)))
        {
            SavedGameButton->SetIsEnabled(true);
        }
    }
}

...

void UTitleWidget::OnSavedGameButtonClicked()
{
    UGameplayStatics::OpenLevel(GetWorld(), TEXT("Play"));
}

...

 

 

 

 

10.2-2 Loading Level

  - 로딩 레벨

    레벨과 레벨 사이에 잠깐 머무는 맵.

    볼륨이 큰 두 개의 맵을 한 순간에 이동하면 부하가 크므로, 두 개의 큰 맵 사이에 작은 맵을 넣음.

    로딩 레벨에 도착하면, 이전 레벨 볼륨을 지우고 새 레벨 볼륨을 준비함. 이런 맵을 Transition 맵이라고 함.

 

  - 로딩 레벨 준비

    Toolbar > File > New Level > Empty Level

    Ctrl + S > Levels 폴더 선택 후 "Loading"

    Content Browser > Game > 새 Blueprint 애셋 > GameModeBase 부모 클래스 > "BP_LoadingGameMode"

    Toolbar > Settings > World Settings > GameMode Override에 BP_TitleGameMode 지정.

    Controllers 폴더 > 새 Blueprint 애셋 > SUIPlayerController 부모 클래스 > "BP_LoadingPlayerController"

    BP_LoadingGameMode > Details > Player Controller Class에 BP_LoadingPlayerController 지정.

 

  - 로딩 레벨에 쓰일 플레이어 컨트롤러

    새 C++ 클래스 > SUIPlayerController 부모 클래스 > "SLoadingPlayerController"

    컴파일 후 BP_LoadingPlayerController의 부모 클래스를 SLoadingPlayerController로 지정.

<hide/>

// SLoadingPlayerController.h

#pragma once

#include "CoreMinimal.h"
#include "Controllers/SUIPlayerController.h"
#include "SLoadingPlayerController.generated.h"

/**
 * 
 */
UCLASS()
class STUDYPROJECT_API ASLoadingPlayerController : public ASUIPlayerController
{
    GENERATED_BODY()

public:
    virtual void BeginPlay() override;
    
};
<hide/>

// SLoadingPlayerController.cpp


#include "SLoadingPlayerController.h"

void ASLoadingPlayerController::BeginPlay()
{
    Super::BeginPlay();
}

 

  - 로딩 레벨에 쓰일 스크린샷 준비

    타이틀 레벨에서 한 것과 똑같은데, 다른 각도에서 한 장 더 찍어서 준비. 이름은 T_LoadingImage.png

 

  - 로딩 레벨 전용 클래스 생성

    새 C++ 클래스 > UserWidget > "SLoadingWidget"

<hide/>

// SLoadingWidget.h

#pragma once

#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "SLoadingWidget.generated.h"

/**
 * 
 */
UCLASS()
class STUDYPROJECT_API USLoadingWidget : public UUserWidget
{
    GENERATED_BODY()

public:
    USLoadingWidget(const FObjectInitializer& ObjectInitializer = FObjectInitializer::Get());
    
};
<hide/>

// SLoadingWidget.cpp


#include "UI/SLoadingWidget.h"

USLoadingWidget::USLoadingWidget(const FObjectInitializer& ObjectInitializer)
    : Super(ObjectInitializer)
{
}

 

  - 로딩 레벨 전용 UI 제작

    UI 폴더 우클릭 > UserInterface > 새 Widget Blueprint 애셋 > SLoadingWidget 부모 클래스 > "WBP_Loading"

    아래 Hierarchy를 참고하여 제작.

 

  - 로딩 전용 UI 연결

    BP_LoadingPlayerController > Details > UIWidget Class에 WBP_Loading 지정.

 

  - 타이틀 레벨에서 로딩 레벨로 이동하기

    문제는 레벨 이동시 생성된 모든 정보가 날아감.

    우리는 OpenLevel() 함수의 Options라는 매개변수를 활용하고자 함.

<hide/>

// STitleWidget.cpp


...

void USTitleWidget::OnNewGameButtonClicked()
{
    UGameplayStatics::OpenLevel(GetWorld(), FName(TEXT("Loading")), false, FString(TEXT("NextLevel=Example")));
      // NextLevel이 Key, Example가 Value임. 그럼 Loading 레벨에서는 NextLevel을 파싱해서 Example 값을 얻어내면 됨.
}

...
<hide/>

// SLoadingWidget.h

...
class STUDYPROJECT_API USLoadingWidget : public UUserWidget
{
    ...

protected:
    virtual void NativeConstruct() override;

private:
    FString NextLevelString;
    
};
<hide/>

// SLoadingPlayerController.cpp


#include "SLoadingPlayerController.h"
#include "Kismet/GameplayStatics.h"
#include "GameFramework/GameModeBase.h"

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

    AGameModeBase* GM = UGameplayStatics::GetGameMode(this);
    FString NextLevelString = UGameplayStatics::ParseOption(GM->OptionsString, FString(TEXT("NextLevel")));
    UGameplayStatics::OpenLevel(GM, *NextLevelString);
}

 

10.2-3 Lobby Level

  - 로비 레벨 기획

    플레이어의 이름, 팀(색상으로 구별)을 결정할 수 있게끔 하려함.

 

  - 로비 레벨 준비

    Toolbar > File > New Level > Empty Level

    Ctrl + S > Levels 폴더 선택 후 "Lobby"

    Content Browser > Game > 새 Blueprint 애셋 > GameModeBase 부모 클래스 > "BP_LobbyGameMode"

    Toolbar > Settings > World Settings > GameMode Override에 BP_LobbyGameMode 지정.

    Controllers 폴더 > 새 Blueprint 애셋 > SUIPlayerController 부모 클래스 > "BP_LobbyPlayerController"

    BP_LobbyGameMode > Details > Player Controller Class에 BP_LobbyPlayerController 지정.

    Characters 폴더 > 새 Blueprint 애셋 > PlayerCharacter > "BP_LobbyPlayerCharacter"

 

  - BP_LobbyPlayerCharacte

    Mesh와 Animation Blueprint에 적절히 설정. 다만 Rotation을 정반대로 카메라 쪽으로 돌림.

    Camera 컴포넌트 추가. X축으로 -400 정도로 설정.

    BP_LobbyGameMode > Details > Default Pawn Class에 BP_LobbyPlayerCharacter 지정.

 

  - 로비 레벨 배치

    Place Actors > Plane을 배치. Transform 초기화. Scale에 100, 100, 1

    PlayerStart도 배치. Transform 초기화 후 Z축 100 설정.

    SpotLight도 배치. PlayerStart를 비추게끔 함.

Plane의 Transform
PlayerStart의 Transform
SpotLight의 Transform

 

  - 로비 레벨 전용 UI 클래스 생성

    새 C++ 클래스 > UserWidget 부모 클래스 > "SLobbyWidget"

    아래와 같이 작성 후 컴파일.

<hide/>

// SLobbyWidget.h

#pragma once

#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "SLobbyWidget.generated.h"

/**
 * 
 */
UCLASS()
class STUDYPROJECT_API USLobbyWidget : public UUserWidget
{
    GENERATED_BODY()

protected:
    virtual void NativeConstruct() override;

    UFUNCTION(BlueprintCallable)
    void Next(int32 InDir);

private:
    UFUNCTION()
    void OnPrevButtonClicked();
    
    UFUNCTION()
    void OnNextButtonClicked();

    UFUNCTION()
    void OnSubmitButtonClicked();

protected:
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = USLobbyWidget, Meta = (AllowPrivateAccess, BindWidget))
    TObjectPtr<class UButton> NextButton;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = USLobbyWidget, Meta = (AllowPrivateAccess, BindWidget))
    TObjectPtr<class UButton> PrevButton;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = USLobbyWidget, Meta = (AllowPrivateAccess, BindWidget))
    TObjectPtr<class UEditableText> EditPlayerName;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = USLobbyWidget, Meta = (AllowPrivateAccess, BindWidget))
    TObjectPtr<class UButton> SubmitButton;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = USLobbyWidget)
    int32 CurrentIndex;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = USLobbyWidget)
    int32 MaxIndex;

    TWeakObjectPtr<class USkeletalMeshComponent> CurrentSkeletalMeshComponent;
    
};
<hide/>

// SLobbyWidget.cpp


#include "SLobbyWidget.h"
#include "SPlayerCharacterSettings.h"
#include "Characters/SPlayerCharacter.h"
#include "Components/Button.h"
#include "EngineUtils.h"
#include "Game/SGameInstance.h"
#include "Components/EditableText.h"
#include "Kismet/GameplayStatics.h"

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

    CurrentIndex = 0;

    const USPlayerCharacterSettings* CharacterSettings = GetDefault<USPlayerCharacterSettings>();
    MaxIndex = CharacterSettings->PlayerCharacterMeshPaths.Num();

    ACharacter* Character = Cast<ACharacter>(GetOwningPlayerPawn());
    CurrentSkeletalMeshComponent = Character->GetMesh();

    // UPROPERTY() 내에 BindWidget 키워드를 적지 않을 경우 작성해주면 되는 코드.
    //PrevButton = Cast<UButton>(GetWidgetFromName(TEXT("PrevButton")));
    //check(nullptr != PrevButton);
    //
    //NextButton = Cast<UButton>(GetWidgetFromName(TEXT("NextButton")));
    //check(nullptr != NextButton);
    //
    //EditName = Cast<UEditableTextBox>(GetWidgetFromName(TEXT("EditName")));
    //check(nullptr != EditName);
    //
    //SubmitButton = Cast<UButton>(GetWidgetFromName(TEXT("SubmitButton")));
    //check(nullptr != SubmitButton);

    PrevButton.Get()->OnClicked.AddDynamic(this, &ThisClass::OnPrevButtonClicked);
    NextButton.Get()->OnClicked.AddDynamic(this, &ThisClass::OnNextButtonClicked);
    SubmitButton.Get()->OnClicked.AddDynamic(this, &ThisClass::OnSubmitButtonClicked);
}

void USLobbyWidget::Next(int32 InDir)
{
    CurrentIndex += InDir;
    if (-1 == CurrentIndex)
    {
        CurrentIndex = MaxIndex - 1;
    }
    if (MaxIndex == CurrentIndex)
    {
        CurrentIndex = 0;
    }

    if (const USPlayerCharacterSettings* CharacterSettings = GetDefault<USPlayerCharacterSettings>())
    {
        FSoftObjectPath SkeletalMeshAssetPath = CharacterSettings->PlayerCharacterMeshPaths[CurrentIndex];

        if (USGameInstance* GI = GetWorld()->GetGameInstance<USGameInstance>())
        {
            if (USkeletalMesh* Asset = GI->StreamableManager.LoadSynchronous<USkeletalMesh>(SkeletalMeshAssetPath))
            {
                CurrentSkeletalMeshComponent->SetSkeletalMesh(Asset);
            }
        }
    }
}

void USLobbyWidget::OnPrevButtonClicked()
{
    Next(-1);
}

void USLobbyWidget::OnNextButtonClicked()
{
    Next(1);
}

void USLobbyWidget::OnSubmitButtonClicked()
{
    FString PlayerName = EditPlayerName->GetText().ToString();
    if (PlayerName.Len() <= 0 || 10 <= PlayerName.Len())
    {
        return;
    }
    
    UGameplayStatics::OpenLevel(GetWorld(), TEXT("Loading"), true, FString(TEXT("NextLevel=Example")));

    /*
    USPlayerCharacterSaveGame* NewPlayerData = NewObject<USPlayerCharacterSaveGame>();
    NewPlayerData->PlayerName = PlayerName;
    NewPlayerData->CurrentLevel = 1;
    NewPlayerData->CurrentEXP = 0;

    if (const ASPlayerState* PS = GetDefault<ASPlayerState>())
    {
        if (true == UGameplayStatics::SaveGameToSlot(NewPlayerData, PS->SaveSlotName, 0))
        {
            UGameplayStatics::OpenLevel(GetWorld(), TEXT("Play"));
        }
        else
        {
            UE_LOG(LogTemp, Error, TEXT("Cannot save the NewPlayerData."));
        }
    }
    */
}

 

  - 로비 레벨 UI 제작

    새 User Interface 애셋 > WidgetBlueprint > SLobbyWidget 부모 클래스 > "WBP_Lobby"

    아래와 같이 WBP_Lobby의 Hierarchy를 구성.

 

  - 로비 레벨 UI 연결

    BP_LobbyPlayerController > Details > UIWidget Class에 WBP_Lobby 지정.

 

  - 타이틀 레벨에 로비 레벨 연결

<hide/>

// STitleWidget.cpp


...

void USTitleWidget::OnNewGameButtonClicked()
{
    UGameplayStatics::OpenLevel(GetWorld(), FName(TEXT("Loading")), true, FString(TEXT("NextLevel=Lobby")));
}

...

 

  - 캐릭터 매시 선택 결과의 Play 레벨 반영

    현재 선택한 캐릭터가 게임 플레이에서도 동일하게 나오도록 하려면,

    현재 선택한 캐릭터 정보를 저장하고 게임 플레이 레벨에서 이를

    로딩하는 기능을 만들어야 함. 

<hide/>

// SPlayerCharacterSaveGame.h

...
class STUDYPROJECT_API USPlayerCharacterSaveGame : public USaveGame
{
    ...

public:
    ...
    
    UPROPERTY()
    int32 CurrentCharacterMeshIndex = 0;
    
};
<hide/>

// SelectWidget.cpp

...

void USelectWidget::OnSubmitButtonClicked()
{
    ...

    USPlayerCharacterSaveGame* NewPlayerData = NewObject<USPlayerCharacterSaveGame>();
    NewPlayerData->PlayerName = PlayerName;
    NewPlayerData->CurrentLevel = 1;
    NewPlayerData->CurrentEXP = 0;
    NewPlayerData->CurrentCharacterMeshIndex = CurrentIndex;

    ...
}
<hide/>

// SPlayerState.h

...
class STUDYPROJECT_API ASPlayerState : public APlayerState
{
    ...

public:
    ...

    int32 GetCurrentEXP() const { return CurrentEXP; }

    int32 GetCurrentCharacterMeshIndex() const { return CurrrentCharacterMeshIndex; }

    ...
    
    void SetCurrentCharacterMeshIndex(int32 InCharacterMeshIndex);

private:
    ...

private:
    ...

    UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category="USStatComponent", meta=(AllowPrivateAccess=true))
    int32 CurrrentCharacterMeshIndex = 0;    
    
};
<hide/>

// SPlayerState.cpp

...
#include "SCharacterSettings.h"

...

void ASPlayerState::PostInitializeComponents()
{
    ...
    
    SetPlayerName(PCSG->PlayerName);
    SetCurrentLevel(PCSG->CurrentLevel);
    SetCurrentCharacterMeshIndex(PCSG->CurrentCharacterMeshIndex);

    SavePlayerCharacterData();
}

...

void ASPlayerState::SetCurrentCharacterMeshIndex(int32 InCharacterMeshIndex)
{
    if (const USCharacterSettings* CS = GetDefault<USCharacterSettings>())
    {
        int32 MaxIndex = CS->CharacterMeshPaths.Num();
        CurrrentCharacterMeshIndex = FMath::Clamp<int32>(InCharacterMeshIndex, 0, MaxIndex);
    }
}

void ASPlayerState::SavePlayerCharacterData()
{
    USPlayerCharacterSaveGame* PCSG = NewObject<USPlayerCharacterSaveGame>();
    PCSG->PlayerName = GetPlayerName();
    PCSG->CurrentLevel = GetCurrentLevel();
    PCSG->CurrentEXP = GetCurrentEXP();
    PCSG->CurrentCharacterMeshIndex = CurrrentCharacterMeshIndex;

    ...
}
<hide/>

// SPlayerCharacter.cpp

...
#include "SPlayerState.h"

...

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

    const USCharacterSettings* CDO = GetDefault<USCharacterSettings>();
    int32 RandIndex = FMath::RandRange(0, CDO->CharacterMeshPaths.Num() - 1);
    
    if (ASPlayerState* PS = Cast<ASPlayerState>(GetPlayerState()))
    {
        CurrentCharacterMeshPath = CDO->CharacterMeshPaths[PS->GetCurrentCharacterMeshIndex()];
    }
    else
    {
        CurrentCharacterMeshPath = CDO->CharacterMeshPaths[RandIndex];
    }

    ...
}

...

 

 

10.2-4 In-Game UI

  - ESC 메뉴 기획

    ResumeGameButton

    현재 진행 중인 게임으로 돌아가는 버튼.

    ReturnTitleButton

    Title Level로 돌아가는 버튼

    ExitGameButton

    게임 종료 버튼

 

  - 기존 ESC 단축키

    기존에는 ESC를 누르면 게임이 종료되었음.

    Editor Preferences > Keyboard Shortcuts > "Play World" 검색

    Stop에 Escape가 지정되어 있음. 이를 Shift + Escape로 지정.

 

  - 입력 추가

    Content Browser > InputActions > 새 Input 애셋 > InputAction > "IA_Menu"

    InputConfigs > IC_PlayerCharacter에도 IA_Menu를 추가하기 위해 SInputConfigData 클래스 수정.

    IMC_PlayerCharacter > Mapping 추가 > IA_Menu 지정하고 단축키는 ESC

<hide/>

// SInputConfigData.h

...
class STUDYPROJECT_API USInputConfigData : public UDataAsset
{
    ...

public:
    ...
    
    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    TObjectPtr<class UInputAction> MenuAction;
    
};
<hide/>

// SPlayerCharacter.h

...
class STUDYPROJECT_API ASPlayerCharacter 
    ...
{
    ...

private:
    ...

    void Menu(const FInputActionValue& InValue);

private:
    ...

};
<hide/>

// SPlayerCharacter.h


...
#include "Kismet/KismetSystemLibrary.h"

...

void ASPlayerCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
    ...

    if (UEnhancedInputComponent* EnhancedInputComponent = Cast<UEnhancedInputComponent>(PlayerInputComponent))
    {
        ...
        EnhancedInputComponent->BindAction(PlayerCharacterInputConfigData->MenuAction, ETriggerEvent::Started, this, &ThisClass::Menu);
    }

    ...
}

...

void ASPlayerCharacter::Menu(const FInputActionValue& InValue)
{
    UKismetSystemLibrary::PrintString(this, FString::Printf(TEXT("ASPlayerCharacter::Menu() has been called.")));
}

 

  - 메뉴 위젯 클래스 생성

    새 C++ 클래스 > UserWidget 부모 클래스 > "SMenuWidget"

    Path > UI

    아래와 같이 작성 후 컴파일.

<hide/>

// SMenuWidget.h

#pragma once

#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "SMenuWidget.generated.h"

/**
 * 
 */
UCLASS()
class STUDYPROJECT_API USMenuWidget : public UUserWidget
{
    GENERATED_BODY()

protected:
    virtual void NativeConstruct() override;

    UFUNCTION()
    void OnResumeGameButtonClicked();

    UFUNCTION()
    void OnReturnTitleButtonClicked();

    UFUNCTION()
    void OnExitGameButtonClicked();
    
private:
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = USMenuWidget, meta = (AllowPrivateAccess, BindWidget))
    TObjectPtr<class UButton> ResumeGameButton;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = USMenuWidget, meta = (AllowPrivateAccess, BindWidget))
    TObjectPtr<class UButton> ReturnTitleButton;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = USMenuWidget, meta = (AllowPrivateAccess, BindWidget))
    TObjectPtr<class UButton> ExitGameButton;
    
};
<hide/>

// SMenuWidget.cpp


#include "UI/SMenuWidget.h"
#include "Components/Button.h"
#include "Kismet/KismetSystemLibrary.h"

void USMenuWidget::NativeConstruct()
{
    ResumeGameButton.Get()->OnClicked.AddDynamic(this, &ThisClass::OnResumeGameButtonClicked);
    ReturnTitleButton.Get()->OnClicked.AddDynamic(this, &ThisClass::OnReturnTitleButtonClicked);
    ExitGameButton.Get()->OnClicked.AddDynamic(this, &ThisClass::OnExitGameButtonClicked);
}

void USMenuWidget::OnResumeGameButtonClicked()
{
    UKismetSystemLibrary::PrintString(this, FString::Printf(TEXT("OnResumeGameButtonClicked() has been called.")));
}

void USMenuWidget::OnReturnTitleButtonClicked()
{
    UKismetSystemLibrary::PrintString(this, FString::Printf(TEXT("OnReturnTitleButtonClicked() has been called.")));
}

void USMenuWidget::OnExitGameButtonClicked()
{
    UKismetSystemLibrary::PrintString(this, FString::Printf(TEXT("OnExitGameButtonClicked() has been called.")));
}

 

  - 메뉴 UI 제작

    Content Browser > Study Project > UI > 새 Blueprint 애셋 > SGamePauseWidget 부모 클래스

    "WBP_Menu" 생성 후 더블클릭.

    아래 그림과 같이 Hierarchy 구성.

 

  - 메뉴 UI 연결

    아래와 같이 작성 후 컴파일.

    BP_PlayerController > Details > MenuWidgetClass에 BP_Menu 지정.

<hide/>

// SPlayerController.h

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/PlayerController.h"
#include "SPlayerController.generated.h"

/**
 * 
 */
UCLASS()
class STUDYPROJECT_API ASPlayerController : public APlayerController
{
    GENERATED_BODY()

public:
    ASPlayerController();

    void ToggleMenu();

protected:
    virtual void BeginPlay() override;

private:
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = ASPlayerController, Meta = (AllowPrivateAccess))
    TSubclassOf<class UUserWidget> MenuWidgetClass;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = ASPlayerController, Meta = (AllowPrivateAccess))
    TObjectPtr<class UUserWidget> MenuWidgetInstance;

    FInputModeGameOnly GameplayInputMode;

    FInputModeUIOnly UIInputMode;

    bool bIsMenuOn = false;
    
};
<hide/>

// SPlayerController.cpp


#include "Controllers/SPlayerController.h"
#include "Blueprint/UserWidget.h"

ASPlayerController::ASPlayerController()
{
    PrimaryActorTick.bCanEverTick = true; 
}

void ASPlayerController::ToggleMenu()
{
    if (false == bIsMenuOn)
    {
        MenuWidgetInstance->SetVisibility(ESlateVisibility::Visible);

        FInputModeUIOnly Mode;
        Mode.SetWidgetToFocus(MenuWidgetInstance->GetCachedWidget());
        SetInputMode(Mode);

        // SetPause(true); 만약 게임 일시 정지를 원한다면.
        bShowMouseCursor = true;
    }
    else
    {
        MenuWidgetInstance->SetVisibility(ESlateVisibility::Collapsed);

        FInputModeGameOnly InputModeGameOnly;
        SetInputMode(InputModeGameOnly);

        // SetPause(false); 만약 게임 일시 정지를 원한다면.
        bShowMouseCursor = false;
    }

    bIsMenuOn = !bIsMenuOn;
}

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

    FInputModeGameOnly InputModeGameOnly;
    SetInputMode(InputModeGameOnly);

    if (true == ::IsValid(MenuWidgetClass))
    {
        MenuWidgetInstance = CreateWidget<UUserWidget>(this, MenuWidgetClass);
        if (true == ::IsValid(MenuWidgetInstance))
        {
            MenuWidgetInstance->AddToViewport(3); // 상위에 띄움.

            MenuWidgetInstance->SetVisibility(ESlateVisibility::Collapsed);
        }
    }
}
<hide/>

// SPlayerCharacter.h


...
#include "Controllers/SPlayerController.h"

...

void ASPlayerCharacter::Menu(const FInputActionValue& InValue)
{
    ASPlayerController* PlayerController = GetController<ASPlayerController>();
    if (true == ::IsValid(PlayerController))
    {
        PlayerController->ToggleMenu();
    }
}

 

 

  - 메뉴 UI 로직 구현

<hide/>

// SMenuWidget.cpp


#include "UI/SMenuWidget.h"
#include "Components/Button.h"
#include "Kismet/KismetSystemLibrary.h"
#include "Controllers/SPlayerController.h"
#include "Kismet/GameplayStatics.h"

void USMenuWidget::NativeConstruct()
{
    ResumeGameButton.Get()->OnClicked.AddDynamic(this, &ThisClass::OnResumeGameButtonClicked);
    ReturnTitleButton.Get()->OnClicked.AddDynamic(this, &ThisClass::OnReturnTitleButtonClicked);
    ExitGameButton.Get()->OnClicked.AddDynamic(this, &ThisClass::OnExitGameButtonClicked);
}

void USMenuWidget::OnResumeGameButtonClicked()
{
    ASPlayerController* PlayerController = Cast<ASPlayerController>(GetOwningPlayer());
    if (true == ::IsValid(PlayerController))
    {
        PlayerController->ToggleMenu();
    }
}

void USMenuWidget::OnReturnTitleButtonClicked()
{
    UGameplayStatics::OpenLevel(GetWorld(), FName(TEXT("Loading")), true, FString(TEXT("NextLevel=Title")));
}

void USMenuWidget::OnExitGameButtonClicked()
{
    UKismetSystemLibrary::QuitGame(this, GetOwningPlayer(), EQuitPreference::Quit, false);
}