Chapter 10. UI
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를 비추게끔 함.



- 로비 레벨 전용 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);
}