💭회고
팀 프로젝트를 작업 중이었다. 이번에는 슈팅 게임을 제작한다. 프로젝트를 진행하면서 TIL까지 작성하는 것은 역시나 어렵다. 구현하기도 빠듯한데 이걸 기록까지 남기는 것은 꽤나...ㅠ
그래도 프젝을 하면서 얻게 된 귀중한 경험을 기록으로 남기는 것은 나중에 큰 자산이 될 것이라 생각한다.
깃... git... 깃!!!!!!! 혼자 커밋하고 푸시하고 풀하고 할 때는 문제가 없지만 팀 작업으로 진행할 때는 꼬일 때가 자주 발생하는 것이 골아프다. 게다가 깃에 푸시하는 과정에서 프로젝트에 해를 끼쳐 팀원 분들에게 죄송할 일이 생길까봐 올릴 때마다 불안불안하다...ㅎㅎ;; 그래도 이제야 쬐끔은 익숙해져 가는 것 같다.
내가 이번 프로젝트에서 맡게 된 역할은 여러가지가 있는데, 일단 오늘은 UI, HUD 부분을 맡았으니 그것에 대한 내용을 정리해보고자 한다.
깃에 대한 내용을 다시 한번 상기하고 가보자.
2025.02.17 - [Dev./기타 개발 관련] - [TIL_250217] Git LFS와 언리얼 엔진
[TIL_250217] Git LFS와 언리얼 엔진
💭회고오늘은 프로젝트 세팅 과정을 진행하면서...언리얼 엔진 프로젝트에서 Git LFS(Large File Storage)를 사용하여 대용량 파일을 효율적으로 관리하는 방법에 대해 다시 한번 알아봤다. 이 글에서
raindrovvv.tistory.com
2025.02.18 - [Dev./기타 개발 관련] - [TIL_250218] Git LFS 사용 시 주의 사항
[TIL_250218] Git LFS 사용 시 주의 사항
📒학습 내용Git LFS 대역폭 문제01: 저장용량에 관한 20GB 제한만 인지하고 있었다.02 : 맵 에셋이 꽤나 용량을 잡아먹는 상태라 일정 부분 도려내서 9GB 정도까지 줄였다.03 : 그런데 문제 발생 🛑04:
raindrovvv.tistory.com
🗺️마인드맵
📒학습 내용
1. UI와 HUD
FPS 게임에서 메인 메뉴와 HUD는 플레이어가 게임 상태와 주요 정보를 직관적으로 파악할 수 있도록 돕는다.
이번 설계에서,
- 메인 메뉴는 게임 시작, 종료 기능을 제공
- HUD는 체력, 스태미너(의지력), 탄약, 무기 이름, 점수 등의 정보를 실시간으로 표시하게 했다.
UI와 HUD를 위젯 형태(모듈 형태)로 설계 + UMG를 활용해서 유지보수와 기능 확장이 쉬워지게 구현해보았다.
그리고 메뉴 레벨(메인메뉴 UI전용)을 따로 만들어서 (StartButton 클릭하면)➡️ (스타팅 맵)게임 레벨로 넘어가게 Flow를 짰다.
정리해보자면,
- 메인 메뉴 레벨에서는
메뉴 위젯이 생성되면서 플레이어 컨트롤러의 입력 모드를 UI 전용으로 전환. 이때 마우스 커서가 보이고, 게임 캐릭터의 입력은 무시되어 UI 상의 버튼만 조작할 수 있게 한다. - 게임 레벨에서는 (여기서 HUD가 보여진다)
메뉴에서 게임 시작 버튼을 클릭하여 레벨이 전환되면, 플레이어 컨트롤러에서 입력 모드를 게임 전용(FInputModeGameOnly)으로 다시 설정해주어야 한다. 이 부분은 레벨 전환 후 초기화 로직에 추가하였다.
이렇게 하면, 메뉴와 게임 레벨에서 각각 다른 입력 모드를 적용하여 UI 조작과 게임 플레이를 명확히 분리할 수 있다고 한다.
2. 메인 메뉴 UI 구현 과정
2.1 C++ 클래스 생성 및 위젯 구성
새로운 C++ Class를 선택해 UserWidget을 상속받는 UCMainMenuWidget 클래스를 생성한다.

[CMainMenuWidget 클래스]
이 클래스의 게임 시작과 종료 요청을 델리게이트로 처리하도록 설계할 것이다.
➡️UI와 게임 로직 간 결합도를 낮추고 유지보수를 용이하게...!
📂 CMainMenuWidget.h 핵심 구조
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnStartGameRequested); // (1)
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnQuitGameRequested); // (2)
UPROPERTY(meta = (BindWidget)) // (3)
class UButton* StartGameButton;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Main Menu") // (4)
FName LevelName = FName("Map_Post-Apocalyptic_NightLight");
(1~2) 델리게이트 선언 : 게임 시작 및 종료 요청을 외부 시스템(예: 게임 모드나 컨트롤러)과 분리하여 이벤트 형태로 처리한다
- 결합도 최소화: UI가 게임 로직을 직접 호출하지 않고 이벤트 방출 → 시스템 간 의존성 제거
- 멀티캐스트 기능: 여러 객체가 동시에 이벤트 수신 가능 (예: 통계 추적 + 사운드 재생)
- 블루프린트 연동: BlueprintAssignable로 BP에서 이벤트 핸들링 가능
(3) 버튼 변수: UMG에서 생성한 버튼과 C++ 코드를 연결하기 위해 BindWidget 메타 태그를 사용한다.
- UserWidget의 NativeConstruct()에서 자동 바인딩
(4) LevelName : 에디터에서 쉽게 레벨 이름을 수정할 수 있도록 UPROPERTY로 노출한다.
CMainMenuWidget.h
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "CMainMenuWidget.generated.h"
/**
*
*/
// 게임 시작 요청 델리게이트
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnStartGameRequested);
// 게임 종료 요청 델리게이트
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnQuitGameRequested);
UCLASS()
class START_API UCMainMenuWidget : public UUserWidget
{
GENERATED_BODY()
public:
// 다른 시스템에서 이 이벤트를 바인딩하여 메뉴 클릭을 처리할 수 있음
UPROPERTY(BlueprintAssignable, Category = "Events")
FOnStartGameRequested OnStartGameRequested;
UPROPERTY(BlueprintAssignable, Category = "Events")
FOnQuitGameRequested OnQuitGameRequested;
protected:
virtual void NativeConstruct() override;
// UMG에서 바인딩된 버튼들 (위젯 블루프린트와 동일한 이름을 사용해야 함)
UPROPERTY(meta = (BindWidget))
class UButton* StartGameButton;
UPROPERTY(meta = (BindWidget))
class UButton* QuitButton;
// 버튼 클릭 이벤트 핸들러
UFUNCTION()
void OnStartGameButtonClicked();
UFUNCTION()
void OnQuitClicked();
// 에디터나 블루프린트에서 수정할 수 있도록 함
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Main Menu")
FName LevelName = FName("Map_Post-Apocalyptic_NightLight");
};
CMainMenuWidget.cpp
#include "CMainMenuWidget.h"
#include "Components/Button.h"
#include "Kismet/GameplayStatics.h"
#include "Kismet/KismetSystemLibrary.h"
#include "Engine/World.h"
#include "GameFramework/PlayerController.h"
void UCMainMenuWidget::NativeConstruct()
{
Super::NativeConstruct();
if (StartGameButton)
{
StartGameButton->OnClicked.AddDynamic(this, &UCMainMenuWidget::OnStartGameButtonClicked);
}
else
{
UE_LOG(LogTemp, Warning, TEXT("StartGameButton is not bound!"));
}
if (QuitButton)
{
QuitButton->OnClicked.AddDynamic(this, &UCMainMenuWidget::OnQuitClicked);
}
else
{
UE_LOG(LogTemp, Warning, TEXT("QuitButton is not bound!"));
}
}
void UCMainMenuWidget::OnStartGameButtonClicked()
{
// 게임 시작 이벤트를 발생
OnStartGameRequested.Broadcast();
// 현재 월드를 가져와서 지정된 레벨로 전환
UWorld* World = GetWorld();
if (World)
{
UE_LOG(LogTemp, Log, TEXT("Opening level: %s"), *LevelName.ToString());
UGameplayStatics::OpenLevel(World, LevelName);
}
else
{
UE_LOG(LogTemp, Error, TEXT("World is null, cannot open level"));
}
}
void UCMainMenuWidget::OnQuitClicked()
{
// 게임 종료 이벤트를 발생
OnQuitGameRequested.Broadcast();
// 현재 월드와 플레이어 컨트롤러를 가져와 게임을 종료
UWorld* World = GetWorld();
if (World)
{
APlayerController* PC = GetOwningPlayer();
UE_LOG(LogTemp, Log, TEXT("Quitting game"));
UKismetSystemLibrary::QuitGame(World, PC, EQuitPreference::Quit, false);
}
else
{
UE_LOG(LogTemp, Error, TEXT("World is null, cannot quit game"));
}
}
- NativeConstruct 함수: 위젯이 생성될 때 실행되며, 각 버튼에 OnClicked 이벤트를 연결한다.
- StartGameButton->OnClicked.AddDynamic(this, &UCMainMenuWidget::OnStartGameButtonClicked);
- OnStartGameButtonClicked 함수: 버튼 클릭 시 델리게이트를 통해 게임 시작 이벤트를 발생시키고, 지정된 레벨로 전환.
- OnStartGameRequested.Broadcast();
- UGameplayStatics::OpenLevel(World, LevelName);
- OnQuitClicked 함수: 버튼 클릭 시 델리게이트를 통해 게임 종료 이벤트를 발생시키고, 게임을 종료하는 로직을 실행.
- OnQuitGameRequested.Broadcast();
- UKismetSystemLibrary::QuitGame(World, PC, EQuitPreference::Quit, false);
2.2 Widget Blueprint를 활용한 UI 디자인
1. 해당 C 클래스를 블루프린트로 상속하여 생성. WBP_MainMenuWidget을 만든다.
2. Canvas Panel과 Vertical Box 같은 기본 컨테이너를 사용해 StartGameButton과 QuitButton을 배치하고, 버튼 상태(일반, 호버, 클릭)에 따라 배경색과 효과를 설정해 직관적인 디자인을 완성해본다.
⚠️주의사항 : 각 위젯 이름은 C++ 코드와 일치시켜야 하며 그렇지 않으면 버튼 바인딩에 문제가 발생할 수 있다.


2.3 버튼 이벤트 바인딩 및 디버깅 방법
레벨 블루프린트를 통해 버튼에 OnClicked 이벤트를 연결해서 제대로 동작되는지 알 수 있다.


3. HUD 구현
게임 플레이 중 화면에 표시될 HUD 위젯은 UMG에서 ProgressBar, TextBlock, Image 등의 컴포넌트를 활용하여 각 요소를 관리하게 한다. 초기 상태에서는 각 값을 기본 값으로 설정하여 시작 상태를 만든다.
[CHUDWidget 클래스]
CHUDWidget.h
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "CHUDWidget.generated.h"
/**
* 게임 플레이 중 표시할 HUD 위젯 구현
*/
UCLASS()
class START_API UCHUDWidget : public UUserWidget
{
GENERATED_BODY()
public:
// 체력
UFUNCTION(BlueprintCallable, Category = "HUD")
void UpdateHealth(float fHealthPercent);
// 스태미너
UFUNCTION(BlueprintCallable, Category = "HUD")
void UpdateStamina(float fStaminaPercent);
// 탄약 정보
UFUNCTION(BlueprintCallable, Category = "HUD")
void UpdateAmmo(int32 iCurrentAmmo, int32 iMaxAmmo);
// 현재 무기 이름을 업데이트
UFUNCTION(BlueprintCallable, Category = "HUD")
void UpdateWeapon(FText WeaponName);
// 점수 업데이트
UFUNCTION(BlueprintCallable, Category = "HUD")
void UpdateScore(int32 iNewScore);
// 피해 효과를 표시하는 함수 (예: 화면이 잠시 붉게 깜빡임)
UFUNCTION(BlueprintCallable, Category = "HUD")
void ShowDamageEffect();
// 아이템 획득 알림을 표시하는 함수 (일정 시간 후 사라짐)
UFUNCTION(BlueprintCallable, Category = "HUD")
void ShowItemPickupNotification(const FText& ItemName);
protected:
virtual void NativeConstruct() override;
UPROPERTY(meta = (BindWidget))
class UProgressBar* HealthBar;
UPROPERTY(meta = (BindWidget))
class UProgressBar* StaminaBar;
UPROPERTY(meta = (BindWidget))
class UTextBlock* AmmoText;
UPROPERTY(meta = (BindWidget))
class UTextBlock* WeaponNameText;
UPROPERTY(meta = (BindWidget))
class UTextBlock* ScoreText;
UPROPERTY(meta = (BindWidget))
class UImage* DamageOverlay;
UPROPERTY(meta = (BindWidget))
class UTextBlock* ItemPickupText;
};
CHUDWidget.h
#include "CHUDWidget.h"
#include "Components/ProgressBar.h"
#include "Components/TextBlock.h"
#include "Components/Image.h"
#include "Engine/World.h"
#include "TimerManager.h"
void UCHUDWidget::NativeConstruct()
{
Super::NativeConstruct();
// 초기 상태 설정
if (HealthBar) { HealthBar->SetPercent(1.0f); }
if (StaminaBar) { StaminaBar->SetPercent(1.0f); }
if (AmmoText) { AmmoText->SetText(FText::FromString("0 / 0")); }
if (WeaponNameText) { WeaponNameText->SetText(FText::FromString("None")); }
if (ScoreText) { ScoreText->SetText(FText::AsNumber(0)); }
// 피해 효과와 아이템 알림은 기본적으로 숨김 처리
if (DamageOverlay) { DamageOverlay->SetVisibility(ESlateVisibility::Hidden); }
if (ItemPickupText) { ItemPickupText->SetVisibility(ESlateVisibility::Hidden); }
}
void UCHUDWidget::UpdateHealth(float fHealthPercent)
{
if (HealthBar)
{
HealthBar->SetPercent(fHealthPercent);
}
}
void UCHUDWidget::UpdateStamina(float fStaminaPercent)
{
if (StaminaBar)
{
StaminaBar->SetPercent(fStaminaPercent);
}
}
void UCHUDWidget::UpdateAmmo(int32 iCurrentAmmo, int32 iMaxAmmo)
{
if (AmmoText)
{
FString AmmoString = FString::Printf(TEXT("%d / %d"), iCurrentAmmo, iMaxAmmo);
AmmoText->SetText(FText::FromString(AmmoString));
}
}
void UCHUDWidget::UpdateWeapon(FText WeaponName)
{
if (WeaponNameText)
{
WeaponNameText->SetText(WeaponName);
}
}
void UCHUDWidget::UpdateScore(int32 iNewScore)
{
if (ScoreText)
{
ScoreText->SetText(FText::AsNumber(iNewScore));
}
}
void UCHUDWidget::ShowDamageEffect()
{
if (DamageOverlay)
{
// 피해를 입었을 때 DamageOverlay를 보이도록 설정
DamageOverlay->SetVisibility(ESlateVisibility::Visible);
FTimerHandle TimerHandle;
// 0.5초 후 DamageOverlay를 숨긴다
GetWorld()->GetTimerManager().SetTimer(TimerHandle, [this]()
{
if (DamageOverlay)
{
DamageOverlay->SetVisibility(ESlateVisibility::Hidden);
}
}, 0.5f, false);
}
}
void UCHUDWidget::ShowItemPickupNotification(const FText& ItemName)
{
if (ItemPickupText)
{
// 아이템 획득 시, 해당 아이템 이름을 포함한 알림 메시지를 생성
ItemPickupText->SetText(FText::Format(NSLOCTEXT("HUD", "ItemPickup", "Picked up: {0}"), ItemName));
ItemPickupText->SetVisibility(ESlateVisibility::Visible);
FTimerHandle TimerHandle;
// 2초 후 아이템 알림을 자동으로 숨김
GetWorld()->GetTimerManager().SetTimer(TimerHandle, [this]()
{
if (ItemPickupText)
{
ItemPickupText->SetVisibility(ESlateVisibility::Hidden);
}
}, 2.0f, false);
}
}
- NativeConstruct 함수: 위젯이 생성될 때 모든 HUD 요소의 초기 상태(체력바, 스태미너바, 텍스트 등)를 설정
- 업데이트 함수들: 게임 내 이벤트에 따라 각 정보를 실시간으로 변경
- 피해 효과와 아이템 획득 알림은 TimerManager를 사용하여 일정 시간 후 자동으로 원래 상태로 복귀시킨다.
HUD 역시 블루프린트로 상속하여 WBP_HUDwidget으로 만들어 디자인해보자.

4. PlayerController 연동
PlayerController에서 UI를 관리하는 것이 일반적이며, 멀티플레이를 고려할 때도 더 자연스럽다고 한다.
[CPlayerController 클래스]
CPlayerController .h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/PlayerController.h"
#include "CPlayerController.generated.h"
class UInputMappingContext;
class UInputAction;
class UUserWidget;
UCLASS()
class START_API ACPlayerController : public APlayerController
{
GENERATED_BODY()
public:
ACPlayerController();
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Input")
UInputMappingContext* InputMappingContext;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Input")
UInputAction* MoveAction;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Input")
UInputAction* LookAction;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Input")
UInputAction* JumpAction;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Input")
UInputAction* RunAction;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Input")
UInputAction* CrouchAction;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Input")
UInputAction* SwitchViewAction;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Input")
UInputAction* QuitAction;
// HUD 위젯 블루프린트 클래스 (게임 플레이 시)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "UI")
TSubclassOf<UUserWidget> HUDWidgetClass;
// 메인 메뉴 위젯 블루프린트 클래스 (메인 메뉴 레벨 시)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "UI")
TSubclassOf<UUserWidget> MainMenuWidgetClass;
protected:
virtual void BeginPlay() override;
UPROPERTY(BlueprintReadOnly, Category = "UI")
UUserWidget* CurrentWidget; // 현재 화면에 보이는 위젯
};
- HUDWidgetClass와 MainMenuWidgetClass는 각각 게임 플레이 화면과 메인 메뉴 화면에 사용될 위젯 클래스
- CurrentWidget: 현재 화면에 표시되는 위젯을 참조하는 변수.
CPlayerController .cpp
// Fill out your copyright notice in the Description page of Project Settings.
#include "CPlayerController.h"
#include "EnhancedInputSubsystems.h"
#include "Blueprint/UserWidget.h"
#include "Kismet/GameplayStatics.h"
ACPlayerController::ACPlayerController()
{
InputMappingContext = nullptr;
MoveAction = nullptr;
LookAction = nullptr;
JumpAction = nullptr;
RunAction = nullptr;
CrouchAction = nullptr;
SwitchViewAction = nullptr;
QuitAction = nullptr;
HUDWidgetClass = nullptr;
MainMenuWidgetClass = nullptr;
CurrentWidget = nullptr;
}
void ACPlayerController::BeginPlay()
{
Super::BeginPlay();
if (ULocalPlayer* LocalPlayer = GetLocalPlayer())
{
if (UEnhancedInputLocalPlayerSubsystem* Subsystem =
LocalPlayer->GetSubsystem<UEnhancedInputLocalPlayerSubsystem>())
{
if (InputMappingContext)
{
Subsystem->AddMappingContext(InputMappingContext, 0);
}
}
}
// 현재 레벨 이름에 따라 알맞는 위젯 생성
UWorld* World = GetWorld();
if (!World) return;
FString CurrentLevelName = World->GetMapName();
UE_LOG(LogTemp, Log, TEXT("Current Level: %s"), *CurrentLevelName);
// 만약 레벨 이름에 "MenuLevel"가 포함되어 있다면 메인 메뉴 위젯을 생성
if (CurrentLevelName.Contains(TEXT("MenuLevel")))
{
if (MainMenuWidgetClass)
{
CurrentWidget = CreateWidget<UUserWidget>(this, MainMenuWidgetClass);
if (CurrentWidget)
{
CurrentWidget->AddToViewport();
// UI 모드 설정: 마우스 커서 활성화, UI 전용 입력 모드 등
FInputModeUIOnly InputMode;
InputMode.SetWidgetToFocus(CurrentWidget->TakeWidget());
SetInputMode(InputMode);
bShowMouseCursor = true;
}
}
}
else // 그렇지 않다면 HUD 위젯을 생성
{
if (HUDWidgetClass)
{
CurrentWidget = CreateWidget<UUserWidget>(this, HUDWidgetClass);
if (CurrentWidget)
{
CurrentWidget->AddToViewport();
FInputModeGameOnly InputMode;
SetInputMode(InputMode);
bShowMouseCursor = false;
}
}
}
}
- 위젯 생성: 현재 레벨의 이름을 확인하여 적합한 위젯(HUD or Main Menu)을 생성하고 화면에 추가!
- MenuLevel이 포함된 레벨에서는 메인 메뉴 위젯을 생성하고, 마우스 커서를 활성화.
- 그 외의 레벨에서는 HUD 위젯을 생성하고, 게임 모드 입력 설정.
💡인사이트
구글링을 해보니 FPS게임은 아래와 같은 패턴으로 개발하면 좋다고 한다.
📌 FPS 개발자에게 추천하는 아키텍처 패턴
- 컴포넌트 기반 설계: HUD/Inventory 등 모듈화 ➡️ 이거는 팀원분께서 적용하여 Weapon 시스템을 개발 중이다.
- 이벤트 버스 시스템: UI 간 간접 통신 ✅
- 데이터 테이블 활용: 무기/아이콘 정보 CSV 관리
- 프로파일러 연동: STAT 매크로로 UI 렌더링 시간 측정 ??
좀 더 조사해보았다.
- 엔티티-컴포넌트 시스템 (ECS): (#프레임워크)
- 개체의 행위를 독립적인 컴포넌트들로 분리하여 높은 재사용성과 유연성을 제공.
- 예: 플레이어, 적, 무기 등을 컴포넌트로 분리하여 관리.
- 서비스 패턴:
- 공통적으로 사용되는 기능을 서비스로 추상화하여 각 모듈이 독립적으로 동작할 수 있게 합니다.
- 예: 사운드 관리, 물리 엔진, AI 시스템 등을 서비스로 구현.
- 상태 패턴 (State Pattern):
- 객체의 상태를 캡슐화하여 상태 전환 로직을 명확하게 분리합니다.
- 예: 플레이어의 상태(걷기, 뛰기, 점프 등)를 독립적인 클래스로 관리.
- 데이터 로더 패턴:
- 데이터를 비동기적으로 로드하고 캐싱하여 성능을 최적화합니다.
- 예: 게임 시작 시 필요한 리소스를 미리 로드하거나 필요할 때 로드하는 방식.
- 이벤트 기반 아키텍처:
- 이벤트를 통해 모듈 간의 결합도를 낮추고 확장성을 높입니다.
- 예: 무기 변경 이벤트, 적 발견 이벤트 등을 정의하여 게임 로직을 간단하게 구현.
🟣오늘의 옵시디언 현황
'Dev. > UE 언리얼 엔진' 카테고리의 다른 글
[TIL_250224] 언리얼 엔진 탄약 UI 연동, 크로스헤어(조준점) 줌인 구현 (0) | 2025.02.24 |
---|---|
[TIL_250220] 언리얼 엔진 디졸브(Dissolve) 이펙트 가이드 (0) | 2025.02.20 |
[TIL_250214] UI 위젯 설계와 실시간 데이터 연동하기 (0) | 2025.02.14 |
[TIL_250213_3] 게임 루프 설계를 통한 게임 흐름 제어 (0) | 2025.02.13 |
[TIL_250213_2] 캐릭터 체력 시스템 구현 (0) | 2025.02.13 |