일정표
| 시간 | 할 일 | 비고 |
| 08:00~10:00 | 코드카타, Atanny, 코드프로그래머스 | |
| 10:00~13:00 | 개인 프로젝트 | |
| 14:00~18:00 | 2시 스크럼 / 언리얼 강의 | |
| 19:00~21:00 | 저녁 스크럼 / 팀 프로젝트 / TIL | |
| 21:00~22:00 | 운동 및 스트레칭 | |
| 22:00~23:30 | 개인 프로젝트 |
*오늘의 코드카타*
문제. 기사단원의 무기
숫자나라 기사단의 각 기사에게는 1번부터 number까지 번호가 지정되어 있습니다. 기사들은 무기점에서 무기를 구매하려고 합니다. 각 기사는 자신의 기사 번호의 약수 개수에 해당하는 공격력을 가진 무기를 구매하려 합니다. 단, 이웃나라와의 협약에 의해 공격력의 제한수치를 정하고, 제한수치보다 큰 공격력을 가진 무기를 구매해야 하는 기사는 협약기관에서 정한 공격력을 가지는 무기를 구매해야 합니다 예를 들어, 15번으로 지정된 기사단원은 15의 약수가 1, 3, 5, 15로 4개 이므로, 공격력이 4인 무기를 구매합니다. 만약, 이웃나라와의 협약으로 정해진 공격력의 제한수치가 3이고 제한수치를 초과한 기사가 사용할 무기의 공격력이 2라면, 15번으로 지정된 기사단원은 무기점에서 공격력이 2인 무기를 구매합니다. 무기를 만들 때, 무기의 공격력 1당 1kg의 철이 필요합니다. 그래서 무기점에서 무기를 모두 만들기 위해 필요한 철의 무게를 미리 계산하려 합니다. 기사단원의 수를 나타내는 정수 number와 이웃나라와 협약으로 정해진 공격력의 제한수치를 나타내는 정수 limit와 제한수치를 초과한 기사가 사용할 무기의 공격력을 나타내는 정수 power가 주어졌을 때, 무기점의 주인이 무기를 모두 만들기 위해 필요한 철의 무게를 return 하는 solution 함수를 완성하시오.
int solution(int number, int limit, int power) {
int answer = 0;
for (int i = 1; i <= number; ++i) {
int divisor_count = 0;
// 1부터 제곱근까지만 약수를 탐색하는 최적화 로직 O(sqrt(N))
for (int j = 1; j * j <= i; ++j) {
if (i % j == 0) {
if (j * j == i) {
divisor_count += 1; // 제곱수인 경우 (예: 4 * 4 = 16) 1개만 카운트
} else {
divisor_count += 2; // 대칭되는 쌍이 있는 경우 2개 카운트
}
}
}
// 협약으로 정해진 제한수치 검사
if (divisor_count > limit) {
answer += power;
} else {
answer += divisor_count;
}
}
return answer;
}
*Unreal 강의*
< (기초반) Behavior Tree로 몬스터 AI / 추격 범위 + 순찰 >
1. AI 모듈 + NavMesh 깔기
1-1. Build.cs에 AI 모듈 추가
Behavior Tree / AIController / NavMesh는 별도 모듈이라, 쓰기 전에 의존성에 등록해야 합니다.
1-2. 레벨에 NavMesh 깔기
AI가 걸어다니려면 걸을 수 있는 바닥 정보(NavMesh) 가 필요합니다.
Place Actors 패널에서 Nav Mesh Bounds Volume 검색 → 레벨로 드래그
디테일의 Brush Settings에서 X / Y를 플레이 구역을 덮을 만큼 크게 Z는 바닥~머리 높이를 감싸게 적당히 설정
키보드 P — 걸을 수 있는 바닥이 초록색으로 보이면 성공
2. AIController — AMonsterBTController
몬스터 몸(BP_Monster)과 두뇌(Behavior Tree)를 잇는 AIController를 만듭니다.
역할은 단순해요 — 소유(Possess)되는 순간 BT를 실행!
New C++ Class → Show All Classes → 검색 AIController → Next
이름 MonsterBTController Class Type Public, 경로에 AI 추가 → Create
3. BT 노드 2개 — 감지 Service + 순찰 Task
행동 트리에 끼울 부품 노드 두 개를 C++로 만듭니다.
Service — 트리가 도는 동안 주기적으로 도는 노드 (여기선 플레이어 감지)
Task — 한 번 실행되는 행동 노드 (여기선 랜덤 순찰 지점 찾기)
3-1. BTService_FindPlayer — 추격 범위 감지
New C++ Class → Show All Classes → 검색 BTService → Next
이름 BTService_FindPlayer, Public, 경로 AI → Create
3-2. BTTask_FindRandomNavLocation — 순찰 지점
New C++ Class → Show All Classes → 검색 BTTaskNode → Next
이름 BTTask_FindRandomNavLocation, Public, 경로 AI → Create
4. Blackboard + Behavior Tree 조립
이제 에셋을 만들어 트리를 그립니다. 전부 Content/Characters/Monsters/AI/ 안에서 폴더 먼저 생성!
4-1. Blackboard — BB_Monster
AI/ 폴더 우클릭 → Artificial Intelligence → Blackboard → 이름 BB_Monster
키 2개 추가 (New Key):
4-2. Behavior Tree — BT_Monster
AI/ 폴더 우클릭 → Artificial Intelligence → Behavior Tree → 이름 BT_Monster
열어서 디테일의 Blackboard Asset = BB_Monster 확인 (자동 연결되기도 함)
아래 트리를 조립합니다
조립 순서:
Root 밑에 Selector 추가
Selector 우클릭 → Add Service → Find Player → 디테일에서 키 2개 + DetectionRange 설정
왼쪽에 Sequence(추격) → 우클릭 Add Decorator → Blackboard → Key= TargetPlayer Key Query=Is Set, Observer Aborts = Both
오른쪽에 Sequence(순찰) → Find Random Nav Location(Key= TargetLocation) → MoveTo(Key= TargetLocation) → Wait(2.0)
Selector 자식은 왼→오 우선순위. 추격 Sequence가 왼쪽이라 플레이어가 범위에 들어오면(=TargetPlayer Is Set) 추격이 먼저 잡힙니다~ 비어 있으면 추격 분기가 실패 → 오른쪽 순찰로 넘어갑니다.
⚠️ Decorator의 Observer Aborts = Both가 핵심! — 이게 있어야 플레이어가 범위에 들어오는 순간 순찰을 끊고 추격으로 벗어나는 순간 추격을 끊고 순찰로 즉시 전환됩니다! 안 켜면 한 행동이 끝날 때까지 안 바뀝니다.
5. BP_Monster에 두뇌 연결 + 1차 PIE
5-1. AIController BP 만들기 (BT 끼우기)
AMonsterBTController 의 BehaviorTreeAsset은 C++에서도 넣을 수 있지만 에셋 경로 하드코딩을 피하려고 BP서브클래스 슬롯에 할당합니다.
Characters/Monsters/AI/ 우클릭 → Blueprint Class → All Classes → MonsterBTController → 이름 BP_AIController_Monster
열어서 디테일 AI → Behavior Tree Asset = BT_Monster → Compile + Save
5-2. BP_Monster가 이 컨트롤러를 쓰게 만들기!
BP_Monster → Class Defaults(또는 디테일) → Pawn 섹션
AI Controller Class = BP_AIController_Monster
Auto Possess AI = Placed in World or Spawned
5-3. 1차 PIE — 움직이긴 하는데…
▶ Play
멀리 있으면 몬스터가 어슬렁어슬렁 순찰~(랜덤 지점으로 이동 → 2초 대기 반복)
추격 범위(1500) 안에 들어가면 플레이어를 향해 직진 추격
멀어지면 다시 순찰로 복귀
6. 몬스터 이동 애니메이션 — BS_Monster_Move
미끄러지는 몬스터에게 걷기/달리기를 입힙니다. 5강에서 배운 BlendSpace 기법을 그대로 재활용 — 단 몬스터는 정면으로만 쫓아오니 좌우(strafe)가 필요 없어서 1D(속도 하나) 면 충분해요. (플레이어는 좌우+앞뒤라 2D였죠~)
6-1. MonsterAnim에 Speed 추가
BlendSpace를 속도로 굴리려면 애님인스턴스가 현재 속도를 알려줘야 합니다.
Public/Animation/MonsterAnim.h — 멤버 + 함수 추가:
6-2. BlendSpace — BS_Monster_Move (1D)
Content/Characters/Monsters/Animations/ 안에서 우클릭 → Animation → Leagacy → Blend Space 1D
Skeleton = UE4_Mannequin_Skeleton → 이름 BS_Monster_Move
축 설정: Axis Name = Speed, Minimum 0, Maximum 600 Snap to Grid True 설정
6-3. ABP_Monster에 BlendSpace 꽂기
4강의 ABP_Monster AnimGraph는 [Idle 포즈] → DefaultSlot → Output Pose 였죠? Idle 포즈 자리를 BlendSpace로 교체합니다!
ABP_Monster → AnimGraph → 기존 Idle 포즈 노드 삭제
Asset Browser에서 BS_Monster_Move 드래그
BlendSpace 노드 Speed 핀 ← Speed
BlendSpace 출력 → DefaultSlot → Output Pose (DefaultSlot, Output은 그대로!)
Compile + Save
7. 최종 PIE 테스트
▶ Play
순찰 — 플레이어가 멀면 몬스터가 걸어서 랜덤하게 돌아다니다 멈췄다 반복
추격 — 추격 범위(1500) 안에 들어가면 달려서 플레이어에게 직진
이탈 — 범위 밖으로 도망가면 추격을 끊고 다시 순찰
사냥 — 쏘면 4강 그대로 HitReact로 흠칫 → 죽으면 쓰러지고 시체가 가라앉음.
< (심화반) UObject 생명주기 - GC와 메모리 관리>
1. 큰 그림: 언리얼 메모리 모델 잡기
1-1. C++ vs 언리얼 GC: 왜 완전히 다를까?
1-2. UObject란? 언리얼 엔진의 핵심 상자
1-3. Outer 체인: 누가 누구의 주인인가?
1. 큰 그림: 언리얼 메모리 모델 잡기 👾
1-1. C++ vs 언리얼 GC: 왜 완전히 다를까?
일반 C++의 문제점
MyClass* ptr = new MyClass(); // 생성
// ... 사용 ...
delete ptr; // 삭제 (필수!)
void ProblematicFunction()
{
MyClass* obj = new MyClass();
if (SomeCondition())
{
return; // 앗! obj를 delete하지 못했다!
}
delete obj; // 이 줄에 영원히 도달하지 못함
}
MyClass* obj = new MyClass();
delete obj;
delete obj; // 💥 같은 메모리를 두 번 지움 = 크래시!
// 또는
obj->DoSomething(); // 💥 이미 지워진 메모리에 접근 = 크래시!
언리얼 GC의 해결책
UObject* obj = NewObject<UObject>(); // 생성
// ... 사용 ...
// delete 안 씀! 언리얼이 알아서 처리
UObject* player = NewObject<UObject>(); // 플레이어 생성
UObject* weapon = NewObject<UObject>(); // 무기 생성
player = nullptr; // 플레이어를 더 이상 참조하지 않음
// 잠시 후 GC가 동작하면:
// - weapon: 여전히 weapon 변수가 참조하고 있음 → 살아남음 ✅
// - player가 가리키던 객체: 아무도 참조 안 함 → 자동 삭제 ✅
언리얼 GC 규칙
- UObject 상속 클래스만 관리
- UPROPERTY로 참조 표시 필수
class UInventory : public UObject { GENERATED_BODY() UPROPERTY() UObject* ImportantItem; // GC가 추적함 UObject* ForgottenItem; // GC가 모름! }; void Example() { UInventory* Inv = NewObject<UInventory>(); Inv->ImportantItem = NewObject<UObject>(); // 안전 Inv->ForgottenItem = NewObject<UObject>(); // 위험 // 나중에... Inv->ForgottenItem->DoSomething(); // 💥 이미 삭제된 객체 접근! } - class UMyClass : public UObject { UPROPERTY() // ✅ 이 참조는 중요하다고 GC에게 알림 UObject* SafeRef; UObject* UnsafeRef; // ❌ GC가 이 참조를 모름 };
- 절대 delete 하면 안 됨
- UObject* obj = NewObject<UObject>(); delete obj; // ❌ 절대 금지! 언리얼 내부가 망가짐 // 올바른 방법: obj = nullptr; // 참조만 끊으면 GC가 알아서 처리
1-2. UObject란? 언리얼 엔진의 핵심 상자
일반 C++ 클래스의 한계
class MyCppCharacter
{
private:
int Health = 100;
std::string Name = "Hero";
public:
void TakeDamage(int Damage) { Health -= Damage; }
int GetHealth() const { return Health; }
};
❌ 블루프린트 지원 안됨, 에디터 통합 안됨, 자동 저장 안됨
UObject의 마법
UCLASS(BlueprintType) // 블루프린트에서 사용 가능하게!
class MYGAME_API UMyCharacter : public UObject
{
GENERATED_BODY()
private:
UPROPERTY(EditAnywhere, BlueprintReadWrite, SaveGame, meta = (AllowPrivateAccess = "true"))
int32 Health = 100; // 에디터 수정 + 블루프린트 접근 + 자동 저장!
public:
UFUNCTION(BlueprintCallable) // 블루프린트에서 호출 가능!
void TakeDamage(int32 Damage);
};
✅ 블루프린트 지원, 에디터 통합, 자동 저장, 메모리 자동 관리
UObject가 제공하는 핵심 기능들
- 블루프린트 통합
- 에디터 통합
- 자동 저장/불러오기
- UPROPERTY(SaveGame) int32 PlayerLevel; // 자동으로 세이브 파일에 포함! UPROPERTY(SaveGame) TArray<FString> CompletedQuests; // 퀘스트 목록도 자동 저장!
- 네트워크 리플리케이션
- UPROPERTY(Replicated) // 이것만으로 네트워크 동기화! int32 PlayerScore; // 함수도 네트워크를 통해 호출 가능 UFUNCTION(Server, Reliable) void ServerFireWeapon();
- 리플렉션
- 메모리 자동 관리
- // 전통적인 C++ Character* player = new Character(); Weapon* sword = new Weapon(); // ... 나중에 반드시 delete player; delete sword; // UObject 방식 UCharacter* player = NewObject<UCharacter>(); UWeapon* sword = NewObject<UWeapon>(); // delete 없어도 GC가 알아서 정리!
언리얼 클래스 계층
UObject (모든 것의 시작)
├── AActor (3D 월드에 존재하는 것들)
│ ├── APawn (조종 가능한 객체)
│ │ └── ACharacter (걸어다니는 캐릭터)
│ ├── AController (두뇌 역할)
│ └── AGameMode (게임 규칙 관리)
├── UActorComponent (액터에 붙이는 부품들)
│ ├── UMeshComponent (모델 표시)
│ └── UMovementComponent (이동 처리)
└── UObject (순수 데이터/로직)
├── UGameInstance (게임 전체 관리)
└── UUserWidget (UI 요소)
- 통일성 - 모든 객체가 같은 방식으로 동작
- 확장성 - 새 기능 추가 시 모든 객체가 자동으로 혜택
- 상호 운용성 - 서로 다른 시스템끼리 쉽게 연동
1-3. Outer 체인: 누가 누구의 주인인가?
Outer = 이름과 소속 계층
🏢 아파트 건물 (World - 게임 세계)
└── 🏠 101호 (Level - 현재 맵)
└── 👤 하연 튜터 (Actor - 플레이어 캐릭터)
├── 📱 스마트폰 (Component - 인벤토리)
│ └── 💬 카카오톡 (SubObject - 아이템)
└── 👛 지갑 (Component - 장비)
├── 💳 신용카드 (SubObject)
└── 💵 현금 (SubObject)
핵심: Outer는 객체의 이름·경로·소속 문맥을 나타내며 GC 소유권이 아니다.
이름과 경로 예시
UItemData* PlayerPotion = NewObject<UItemData>(PlayerInventory, TEXT("Potion"));
UItemData* ChestPotion = NewObject<UItemData>(ChestInventory, TEXT("Potion"));
PlayerPotion->GetName(); // "Potion"
ChestPotion->GetName(); // "Potion"
PlayerPotion->GetPathName(); // ...Player.Inventory.Potion
ChestPotion->GetPathName(); // ...Chest.Inventory.Potion
// 짧은 이름이 같아도 Outer가 다르면 전체 경로가 달라진다.
구분: Outer는 “어디에 속하는가?”, Strong Reference는 “누가 살아 있게 붙잡는가?”를 나타낸다.
메모리 소유권과 Outer 구분
- 일반 C++ 수명 관리와 UObject GC 구분
// Actor 전용 파괴 API void PlayerDied() { player->Destroy(); // Actor를 World에서 제거하도록 요청 // Component 정리는 Actor 시스템의 규칙이며 일반적인 Outer 규칙이 아님 } - // 일반 C++에서 수동으로 관리하는 예 void PlayerDied() { delete player->inventory; delete player->weapon; delete player->armor; delete player->skills[0]; delete player->skills[1]; // ... 수십 개의 객체를 일일이 정리 delete player; // 하나라도 빼먹으면 메모리 누수! }
- GC는 등록된 Strong Reference를 따라 작동
- Outer는 이름과 논리적 소속 구조를 표현
World와 Actor의 전용 정리 과정
// 레벨 전환 예시: World/Level 시스템의 동작
GetWorld()->LoadLevel(TEXT("NewLevel"));
// 실제로는 이런 일들이 자동으로
// 1. 현재 Level의 모든 Actor들에게 "곧 사라진다" 알림
// 2. 각 Actor의 모든 Component들 정리
// 3. 각 Actor가 관리하는 Component와 등록된 Subobject 정리
// 4. Level 자체 삭제
// → 맵 전체가 깔끔하게 정리됨!
void APlayerCharacter::Die()
{
Destroy(); // 플레이어가 죽을 때
// Actor 시스템이 관리하는 Component는 함께 정리됨
// 일반 UObject인 아이템·장비·스킬의 수명은
// UPROPERTY 등 명시적인 Strong Reference로 관리해야 함
}
NewObject에서 Outer 지정
// NewObject의 기본 형태
UObject* NewObj = NewObject<UMyClass>(
this, // ← 이 부분이 Outer (주인)
UMyClass::StaticClass(), // 클래스 타입
TEXT("ObjectName") // 이름 (선택)
);
// 다양한 Outer 선택 예시
UObject* TempObj = NewObject<UObject>(GetTransientPackage()); // 임시 객체
UObject* WorldObj = NewObject<UObject>(GetWorld()); // 월드 레벨
UObject* GameObj = NewObject<UObject>(GetGameInstance()); // 게임 전체
UActorComponent* Comp = NewObject<UActorComponent>(MyActor); // 액터 소유
자주 하는 실수들
- Outer만으로 수명을 보장하려는 코드
- // ❌ Outer만 지정하고 GC 생존도 보장된다고 가정 UItemData* Item = NewObject<UItemData>(Inventory, TEXT("Potion")); // Inventory는 이름·소속을 위한 Outer // ✅ 실제 수명 소유자가 UPROPERTY Strong Reference로 보관 Inventory->Items.Add(Item); // Items는 UPROPERTY TArray<TObjectPtr<UItemData>>
- Outer 변경의 영향
- // ⚠️ 주의: 중간 객체의 Outer 변경 UObject* Parent = NewObject<UObject>(this); UObject* Child = NewObject<UObject>(Parent); // 변경 전 이름 경로: this.Parent.Child // 변경 후 이름 경로: SomeOtherObject.Parent.Child Parent->Rename(nullptr, SomeOtherObject); // Parent를 다른 곳으로 이동 // Child의 Outer는 Parent 그대로이며 전체 경로가 함께 바뀜 // 저장·참조 경로에 영향을 줄 수 있으므로 드물게 사용
2. 생성, 수명, GC 핵심
2-1. 생성 패턴 : CreateDefaultSubobjectvsNewObject
2-2. UObject 라이프사이클
2-3. GC 동작 원리: Mark & Sweep
2-4. UPROPERTY의 진실: GC가 보는 것과 못 보는 것
2-5. AddToRoot: 강력하지만 위험한 최후의 수단
2. 생성, 수명, GC 핵심
2-1. 생성 패턴: CreateDefaultSubobject vs NewObject
CreateDefaultSubobject - 미리 준비
class AMyActor : public AActor
{
public:
AMyActor() // 생성자: 이 액터가 태어날 때 딱 한 번 실행
{
// "이 액터의 필수품들을 미리 준비해둬"
MeshComponent = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("MeshComponent"));
CollisionComponent = CreateDefaultSubobject<USphereComponent>(TEXT("Collision"));
}
private:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components")
UStaticMeshComponent* MeshComponent;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components")
USphereComponent* CollisionComponent;
};
NewObject - 필요할 때
void AMyActor::OnLevelUp() // 레벨업했을 때 호출되는 함수
{
if (PlayerLevel >= 10) // 10레벨 이상이면
{
// "지금 당장 특수능력을 만들어줘!"
USpecialAbility* NewAbility = NewObject<USpecialAbility>(
this, // 누가 주인? → 이 액터
USpecialAbility::StaticClass(), // 어떤 타입?
TEXT("FireballAbility") // 이름은 뭐로 할까?
);
Abilities.Add(NewAbility); // 능력 목록에 추가
}
}
언제 무엇을 쓸까?
구분 CreateDefaultSubobject NewObject| 사용 시점 | 생성자에서만 | 언제든지 |
| 목적 | 기본 구성품 (필수) | 동적 생성 (선택) |
| 에디터 지원 | ✅ 완전 통합 | ❌ 런타임 전용 |
| 예측 가능성 | ✅ 항상 같음 | ❌ 상황마다 다름 |
| 사용 예시 | 메시, 카메라, 기본 컴포넌트 | 아이템, 능력, 임시 객체 |
2-2. UObject 라이프사이클
UCLASS()
class UMyObject : public UObject
{
GENERATED_BODY()
public:
// 1단계: "방금 태어났어요!"
virtual void PostInitProperties() override
{
Super::PostInitProperties(); // 부모의 초기화부터 먼저!
// 기본값 검증 및 보정
if (SomeImportantValue <= 0)
{
SomeImportantValue = 100; // 잘못된 값 보정
}
}
// 2단계: "저장된 데이터를 불러왔어요!"
virtual void PostLoad() override
{
Super::PostLoad();
// 구버전 데이터를 신버전으로 변환
if (DataVersion < CURRENT_VERSION)
{
UpgradeDataFormat();
}
}
// 3단계: "곧 죽을 예정이에요..."
virtual void BeginDestroy() override
{
// 다른 객체들에게 작별 인사
NotifyOthersAboutMyDeath();
Super::BeginDestroy(); // 부모의 정리 작업
}
// 4단계: "완전히 사라져요"
virtual void FinishDestroy() override
{
// 마지막 정리 작업
Super::FinishDestroy();
}
};
Super:: 호출의 중요성
// ❌ 이렇게 하면 큰일 납니다!
virtual void PostInitProperties() override
{
// Super::PostInitProperties(); 생략함!
MyCustomInitialization();
// → 언리얼 엔진의 기본 초기화가 안 됨!
}
// ✅ 올바른 방법
virtual void PostInitProperties() override
{
Super::PostInitProperties(); // 부모의 초기화 먼저
MyCustomInitialization(); // 그 다음에 내 초기화
}
GetWorld() 사용법
void UMyObject::SomeFunction()
{
UWorld* World = GetWorld(); // 내가 속한 월드 가져오기
if (World) // 월드가 있다면
{
// 월드 관련 작업들 가능
AActor* NewActor = World->SpawnActor<AActor>(); // 액터 스폰
// 타이머 설정, 다른 액터 찾기 등
}
else // 월드가 없다면
{
// 순수 데이터 처리만 가능
ProcessPureData();
CalculateNumbers();
}
}
2-3. GC 동작 원리: Mark & Sweep
GC 작업 과정
게임 상황: 플레이어가 무기를 버렸다
시작 상태:
[World] → [PlayerController] → [PlayerCharacter] → [InventoryComponent]
↘
[WeaponItem] → [WeaponMesh]
무기를 버린 후:
[World] → [PlayerController] → [PlayerCharacter] → [InventoryComponent]
(연결 끊어짐)
[WeaponItem] → [WeaponMesh]
GC 실행:
1. Mark 단계:
- World부터 시작 ✓
- PlayerController 표시 ✓
- PlayerCharacter 표시 ✓
- InventoryComponent 표시 ✓
- WeaponItem은 아무도 참조 안 함 → 표시 없음 ✗
- WeaponMesh도 표시 없음 ✗
2. Sweep 단계:
- WeaponItem 삭제
- WeaponMesh 삭제
수동 GC 실행
// 특별한 상황에서 강제 실행
GEngine->ForceGarbageCollection(true);
// ⚠️ 주의: 남용하면 게임이 끊겨요!
// GC 실행 중에는 게임이 잠깐 멈춤
2-4. UPROPERTY의 진실: GC가 보는 것과 못 보는 것
왜 UPROPERTY 볼까?
- 성능 때문
- class UMyComplexObject : public UObject { // UObject 포인터들 (GC가 관심있어 함) UObject* Obj1; UObject* Obj2; // 일반 C++ 타입들 (GC가 관심없음) int32 SomeNumber; float SomeFloat; FString SomeString; TArray<int32> NumberArray; // 외부 라이브러리 타입들 (GC가 알 수 없음) std::vector<int> CppVector; std::shared_ptr<SomeClass> SharedPtr; // 그 외 수십, 수백 개의 변수들... };
- 타입 안전성 때문
- class UConfusingObject : public UObject { void* VoidPointer; // 이게 뭘 가리키는지 알 수 없음 int32* IntPointer; // int를 가리키는 포인터 UObject* ObjectPointer; // UObject를 가리키는 포인터 // GC는 이 중에서 뭘 체크해야 할까요? // → ObjectPointer만 체크하면 됨! // → 하지만 그걸 어떻게 구분할까? // → UPROPERTY로 표시하면 확실! };
- 의도 파악 때문
- class UIntentionExample : public UObject { UObject* ImportantReference; // 이건 중요한 참조일까? 임시일까? UObject* TemporaryPointer; // 잠깔 쓰고 버릴 포인터일까? UObject* CachePointer; // 성능을 위한 캐시일까? // 개발자만이 각 포인터의 의도를 알고 있음 // UPROPERTY = "이건 중요하니까 GC가 신경써줘" // UPROPERTY 없음 = "이건 임시니까 신경 안 써도 돼" };
UPROPERTY 있음 vs. 없음
UCLASS()
class UGCTestObject : public UObject
{
GENERATED_BODY()
public:
// ✅ UPROPERTY 있음 - GC가 보호함
UPROPERTY()
UObject* SafeReference;
UPROPERTY()
TArray<UObject*> SafeArray;
// ❌ UPROPERTY 없음 - GC가 모름
UObject* UnsafeReference;
TArray<UObject*> UnsafeArray;
void TestTheReferences()
{
// 테스트용 객체들 생성
UObject* TestObj1 = NewObject<UObject>(this);
UObject* TestObj2 = NewObject<UObject>(this);
// 참조 설정
SafeReference = TestObj1; // ✅ GC가 TestObj1을 보호
UnsafeReference = TestObj2; // ❌ GC가 TestObj2를 모름!
// 잠시 후 GC 실행되면...
// TestObj1 → 살아남음 ✅
// TestObj2 → 삭제됨! 💥
}
};
다양한 UPROPERTY 적용 사례
- 기본 참조들
- class UBasicReferences : public UObject { UPROPERTY() UObject* SingleObject; // 단일 객체 참조 UPROPERTY() TArray<UObject*> ObjectArray; // 배열 참조 (배열 안의 모든 객체 보호됨) UPROPERTY() TMap<FString, UObject*> ObjectMap; // 맵 참조 (Value 객체들만 보호됨) };
- 구조체 안의 참조도 보호됨
- USTRUCT() struct FMyStruct { GENERATED_BODY() UPROPERTY() UObject* NestedObject; // 구조체 안의 참조도 GC가 봄! }; UCLASS() class UStructContainer : public UObject { UPROPERTY() FMyStruct MyStruct; // 구조체 전체를 UPROPERTY로 하면 // 구조체 안의 UObject 참조들도 모두 보호됨! };
UPROPERTY 없는 참조 안전하게 다루기
class UAdvancedUsage : public UObject
{
// 이런 것들은 UPROPERTY가 안 됨
TSharedPtr<FMyData> SharedData; // 스마트 포인터
std::unique_ptr<SomeClass> UniquePtr; // C++ 스마트 포인터
public:
// 방법 1: 수명을 명확히 관리
void SafeUsagePattern()
{
auto Data = MakeShared<FMyData>();
SharedData = Data;
ProcessData(SharedData.Get()); // 이 함수 안에서만 사용
SharedData.Reset(); // 명시적 해제
}
// 방법 2: BeginDestroy에서 정리
virtual void BeginDestroy() override
{
SharedData.Reset(); // 죽기 전에 모든 포인터 정리
UniquePtr.reset();
Super::BeginDestroy();
}
};
2-5. AddToRoot: 강력하지만 위험한 최후의 수단
올바른 사용법 1: 싱글톤
// 게임 전체에서 하나만 존재해야 하는 매니저
class USoundManager : public UObject
{
static USoundManager* Instance;
public:
static USoundManager* GetInstance()
{
if (!Instance)
{
Instance = NewObject<USoundManager>(GetTransientPackage());
Instance->AddToRoot(); // Root에 추가 → 게임이 끝날 때까지 보존
}
return Instance;
}
static void Shutdown()
{
if (Instance)
{
Instance->RemoveFromRoot(); // Root에서 제거 (중요!)
Instance = nullptr;
}
}
};
// 게임 종료 시 - 반드시 호출해야 함!
void UMyGameInstance::Shutdown()
{
USoundManager::Shutdown(); // 수동으로 정리
Super::Shutdown();
}
올바른 사용법 2: 비동기 작업 보호
// 비동기 로딩 중에는 삭제되면 안 되는 객체
class UAssetLoader : public UObject
{
public:
void LoadAssetAsync(const FSoftObjectPath& AssetPath)
{
AddToRoot(); // 로딩 시작 전에 자신을 보호
bIsLoading = true;
// 비동기 로딩 시작
UAssetManager& AssetManager = UAssetManager::Get();
StreamableHandle = AssetManager.LoadAssetAsync(AssetPath,
FStreamableDelegate::CreateUObject(this, &UAssetLoader::OnLoadComplete)
);
}
private:
void OnLoadComplete()
{
bIsLoading = false;
RemoveFromRoot(); // 로딩 완료 → 보호 해제
// 이제 이 객체는 필요없으면 GC가 정리할 수 있음
}
};
올바른 사용법 3: 임시 보호
// 위험한 작업 중 임시 보호
void RiskyOperation(UObject* ImportantObject)
{
ImportantObject->AddToRoot(); // 위험한 작업 전에 임시 보호
LoadSomeHeavyAssets(); // GC 유발 가능한 작업들
CreateThousandsOfObjects();
CallComplexBlueprintFunction();
ImportantObject->RemoveFromRoot(); // 작업 완료 즉시 보호 해제
}
위험한 사용 패턴들
- RemoveFromRoot 없는 무한 보존
- // ❌ 이렇게 하면 메모리 누수! class UBadManager : public UObject { GENERATED_BODY() TArray<UObject*> ImportantData; public: void CreateImportantData() { UObject* Data = NewObject<UObject>(this); Data->AddToRoot(); // Root에 추가 ImportantData.Add(Data); // 문제: 언제 RemoveFromRoot()를 할지 정하지 않음! // 결과: 이 객체는 게임이 끝날 때까지 메모리에 영원히 남음 } };
- 불필요한 Root 추가
- // ❌ 이미 보호받는 객체에 또 Root 추가 class AMyActor : public AActor { public: void BeginPlay() override { Super::BeginPlay(); AddToRoot(); // ❌ 잘못된 생각: "혹시 몰라서 Root에 추가하자" // 문제: Actor는 이미 World가 Root 역할을 함! // 중복 보호 = 불필요한 성능 낭비 + 복잡성 증가 } };
더 좋은 대안들
- UPROPERTY 사용 (95% 상황에서 최선)
- // 대안 1: UPROPERTY 사용 class UGoodManager : public UObject { UPROPERTY() // AddToRoot 없이도 안전하게 관리 TArray<UObject*> ManagedObjects; public: void AddManagedObject(UObject* NewObj) { ManagedObjects.Add(NewObj); // UPROPERTY로 충분히 보호됨 } };
- Outer 관계 활용
- // 대안 2: Outer 관계 활용 class UDataContainer : public UObject { GENERATED_BODY() public: UObject* CreateSubData(TSubclassOf<UObject> DataClass) { // this가 Outer → Container가 사라지면 SubData도 자동 정리 return NewObject<UObject>(this, DataClass); } };
- 게임 인스턴스나 월드에 등록
- // 대안 3: 게임 인스턴스나 월드에 등록 class UGlobalDataManager : public UObject { GENERATED_BODY() public: static UGlobalDataManager* CreateInstance(UGameInstance* GameInstance) { // GameInstance가 Outer → 게임이 끝날 때까지 보존 return NewObject<UGlobalDataManager>(GameInstance); // AddToRoot() 불필요! GameInstance가 이미 Root이므로 자동 보호 } };
3. 참조 관리 기초
3-1. Strong vs Weak: 선택 규칙과 안전 패턴
3-2. 순환 참조 차단: 메모리 누수의 주범을 막자
3. 참조 관리 기초 🛹
3-1. Strong vs Weak: 선택 규칙과 안전 패턴
Strong Reference (강한 참조)
UCLASS()
class UInventory : public UObject
{
GENERATED_BODY()
public:
// 이게 Strong Reference (강한 참조)
UPROPERTY()
TArray<UObject*> Items; // UObject* = 일반 포인터 = Strong
};
UInventory* Inventory = NewObject<UInventory>();
UItem* Sword = NewObject<UItem>();
Inventory->Items.Add(Sword); // Inventory가 Sword를 "소유"
// 이제 Sword는 Inventory가 살아있는 한 절대 삭제되지 않음
Weak Reference (약한 참조)
UCLASS()
class UItemWidget : public UObject
{
GENERATED_BODY()
public:
// 이게 Weak Reference (약한 참조)
UPROPERTY()
TWeakObjectPtr<AActor> ObservedActor; // TWeakObjectPtr = Weak
void UpdateDisplay()
{
// Weak 참조는 항상 유효성 체크 필수!
if (ObservedActor.IsValid()) // "아직 살아있나?"
{
AActor* Actor = ObservedActor.Get(); // 실제 포인터 가져오기
FString Name = Actor->GetName(); // 이제 안전하게 사용
}
else
{
// 대상이 이미 삭제됨 → UI를 비우거나 기본값 표시
}
}
};
사용법 비교
// Strong Reference 체크
UPROPERTY()
AActor* MyActor;
void CheckStrongRef()
{
if (IsValid(MyActor)) // 전역 함수 IsValid()
{
MyActor->DoSomething(); // 바로 사용 가능
}
}
// Weak Reference 체크
UPROPERTY()
TWeakObjectPtr<AActor> MyActorWeak;
void CheckWeakRef()
{
if (MyActorWeak.IsValid()) // 멤버 함수 IsValid()
{
AActor* Actor = MyActorWeak.Get(); // Get()으로 포인터 획득
Actor->DoSomething(); // 이제 안전하게 사용
}
}
실제 게임 상황에서의 선택 기준
- 전형적인 소유 관계 (Strong)
- // 인벤토리 시스템 class UInventory : public UObject { GENERATED_BODY() public: UPROPERTY() TArray<UItem*> Items; // Strong: 인벤토리가 아이템들을 소유 }; // 스킬 시스템 class USkillManager : public UObject { GENERATED_BODY() public: UPROPERTY() TArray<USkill*> LearnedSkills; // Strong: 매니저가 스킬들을 관리 };
- 전형적인 관찰 관계 (Weak)
- // UI 시스템 class UHealthBarWidget : public UUserWidget { GENERATED_BODY() public: UPROPERTY() TWeakObjectPtr<ACharacter> WatchedCharacter; // Weak: UI는 관찰만 함 }; // 미니맵 시스템 class UMinimapWidget : public UUserWidget { GENERATED_BODY() public: UPROPERTY() TWeakObjectPtr<APawn> TrackedPlayer; // Weak: 추적만 함 };
자주 하는 실수들
- 실수 1: UPROPERTY 없는 생 포인터
- // ❌ 절대 금지! - GC가 모르는 포인터 class UBadExample : public UObject { UObject* DangerousPtr; // UPROPERTY 없음 = 크래시 위험! };
- 실수 2: Weak 참조를 체크 없이 사용
- // ❌ 위험한 코드 - 삭제된 객체에 접근할 수 있음 void BadWeakUsage() { AActor* Actor = WeakPtr.Get(); // IsValid() 체크 없이 바로 Get() Actor->DoSomething(); // 💥 크래시 가능성! } // ✅ 올바른 코드 - 항상 체크 후 사용 void GoodWeakUsage() { if (WeakPtr.IsValid()) // 먼저 살아있는지 확인 { AActor* Actor = WeakPtr.Get(); Actor->DoSomething(); // 안전 } }
- 실수 3: 모든 걸 Hard로 하기
3-2. 순환 참조 차단: 메모리 누수의 주범을 막자
문제: 순환 참조
// ❌ 순환 참조 - 재앙의 시작
class UParent : public UObject
{
UPROPERTY()
UChild* Child; // Parent가 Child를 Strong 참조
};
class UChild : public UObject
{
UPROPERTY()
UParent* Parent; // Child도 Parent를 Strong 참조 (문제!)
};
Parent ─(강한 참조)→ Child
↑ ↓
└──(강한 참조)───────┘
"나는 저 애가 있어야 살 수 있어!"
"나도 저 애가 있어야 살 수 있어!"
결과: 둘 다 영원히 메모리에서 안 사라짐
해결법 1: 한쪽을 Weak으로
// ✅ 올바른 코드 - 순환 참조 해결!
UCLASS()
class UParent : public UObject
{
GENERATED_BODY()
public:
UPROPERTY()
UChild* Child; // Parent → Child는 Strong (소유)
void CreateChild()
{
Child = NewObject<UChild>(this);
Child->SetParent(this); // 자식에게 부모 알려주기
}
};
UCLASS()
class UChild : public UObject
{
GENERATED_BODY()
public:
UPROPERTY()
TWeakObjectPtr<UParent> Parent; // Child → Parent는 Weak (역참조)
void SetParent(UParent* NewParent)
{
Parent = NewParent;
}
void DoSomethingWithParent()
{
if (Parent.IsValid()) // 항상 체크!
{
UParent* P = Parent.Get();
// 안전하게 부모 사용
}
}
};
Parent ─(강한 참조)→ Child
↑ ↓
└──(약한 참조)───────┘
Parent: "나는 Child를 소유하고 책임진다"
Child: "Parent를 지켜보긴 하지만 붙잡고 있지는 않아"
결과: Parent가 필요없어지면 Child도 함께 정리됨
실제 게임 예시들
// 인벤토리 시스템
class UInventory : public UObject
{
GENERATED_BODY()
public:
UPROPERTY()
TArray<UItem*> Items; // Strong: 인벤토리가 아이템 소유
};
class UItem : public UObject
{
GENERATED_BODY()
public:
UPROPERTY()
TWeakObjectPtr<UInventory> OwnerInventory; // Weak: 아이템이 소유자 참조
void UseItem()
{
if (OwnerInventory.IsValid())
{
UInventory* Inv = OwnerInventory.Get();
// 인벤토리에서 자신을 제거하거나 다른 작업 수행
}
}
};
// UI 시스템
class UPlayerHUD : public UUserWidget
{
GENERATED_BODY()
public:
UPROPERTY()
TArray<UWidget*> ChildWidgets; // Strong: HUD가 자식 위젯들 소유
};
class UHealthBar : public UUserWidget
{
GENERATED_BODY()
public:
UPROPERTY()
TWeakObjectPtr<UPlayerHUD> ParentHUD; // Weak: 자식이 부모 참조
};
해결법 2: 인터페이스로 의존성 줄이기
// 1. 인터페이스 정의 - "체력을 가진 것"
UINTERFACE()
class UHealthOwner : public UInterface
{
GENERATED_BODY()
};
class IHealthOwner
{
GENERATED_BODY()
public:
virtual float GetHealth() const = 0;
virtual void TakeDamage(float Damage) = 0;
};
// 2. 캐릭터가 인터페이스 구현
UCLASS()
class AMyCharacter : public AActor, public IHealthOwner
{
GENERATED_BODY()
float Health = 100.0f;
public:
virtual float GetHealth() const override { return Health; }
virtual void TakeDamage(float Damage) override { Health -= Damage; }
};
// 3. UI가 인터페이스로 소통
UCLASS()
class UHealthBar : public UUserWidget
{
GENERATED_BODY()
UPROPERTY()
TWeakObjectPtr<UObject> Target; // 구체적인 타입을 모름!
public:
void UpdateHealthBar()
{
if (!Target.IsValid()) return;
UObject* Obj = Target.Get();
// 인터페이스를 구현했는지 확인
if (Obj->GetClass()->ImplementsInterface(UHealthOwner::StaticClass()))
{
// 인터페이스를 통해 소통
IHealthOwner* HealthOwner = Cast<IHealthOwner>(Obj);
float CurrentHealth = HealthOwner->GetHealth();
// UI 업데이트...
}
}
};
해결법 3: 델리게이트로 이벤트 기반 소통
class UEventSender : public UObject
{
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnHealthChanged, float, NewHealth);
UPROPERTY(BlueprintAssignable)
FOnHealthChanged OnHealthChanged;
void ChangeHealth(float NewHealth)
{
OnHealthChanged.Broadcast(NewHealth); // 이벤트 발생
}
};
class UEventReceiver : public UObject
{
public:
void StartListening(UEventSender* Sender)
{
if (Sender)
{
// 이벤트 바인딩
Sender->OnHealthChanged.AddDynamic(this, &UEventReceiver::OnHealthChanged);
}
}
virtual void BeginDestroy() override
{
// ⚠️ 중요: 죽기 전에 반드시 언바인딩!
if (EventSender && IsValid(EventSender))
{
EventSender->OnHealthChanged.RemoveDynamic(this, &UEventReceiver::OnHealthChanged);
}
Super::BeginDestroy();
}
UFUNCTION()
void OnHealthChanged(float NewHealth)
{
// 이벤트 처리
}
private:
UPROPERTY()
TWeakObjectPtr<UEventSender> EventSender; // Weak 참조로 저장
};
*팀 프로젝트*
작업한 내용들
[1. 배송 콤보 시스템]
1. UTeamScoreComponent를 보면 ComboCount가 단순 Replicated로만 지정되어 있어서, 클라이언트 UI가 실시간으로 바뀐 수치를 인지할 통로(델리게이트)가 없는 상태. UI에 맞게 ReplicatedUsing 구조로 리팩토링.
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnComboChangedSignature, int32, NewComboCount);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FOnScoreAddedSignature, int32, AddedScore, int32, BaseScore, int32, CurrentCombo);
// [UI] 콤보 복제 수신 콜백 함수
UFUNCTION()
void OnRep_ComboCount();
// [UI] 현재 콤보 카운트 반환 게터 함수
UFUNCTION(BlueprintCallable, Category = "ParcelUI")
int32 GetComboCount() const;
// [UI] 콤보 위젯 바인딩 Broadcast
UPROPERTY(BlueprintAssignable, Category = "ParcelUI|Events")
FOnComboChangedSignature OnComboChanged;
// [UI] 콤보 카운트 복제 알림
void UTeamScoreComponent::OnRep_ComboCount()
{
GAMERULE_LOG(Log, TEXT("[클라이언트] ComboCount 수신 → %d"), ComboCount);
if (OnComboChanged.IsBound())
{
OnComboChanged.Broadcast(ComboCount);
}
}
2. HUDWidget 수정
OnComboChanged 이벤트를 가로채 문자열을 포맷팅한 후, WBP로 던져주는 기능과 멀티플레이 안전장치를 추가.
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "GameplayTagContainer.h"
#include "ParcelHUDWidget.generated.h"
class AParcelGameState;
class ADeliveryBox;
/**
* UParcelWidget
* 인게임 HUD 요소의 이벤트 바인딩하고 관리하는 베이스 위젯
* 담당자 : JYW
*/
UCLASS()
class PARCEL_KNIGHT_API UParcelHUDWidget : public UUserWidget
{
GENERATED_BODY()
protected:
// 위젯의 BeginPlay 함수
virtual void NativeConstruct() override;
// 로컬 플레이어와 데이터를 바인딩
void TryBindUIEvents();
// WBP에서 사용할 수 있도록 열어두는 이벤트 (델리게이트 -> 브로드캐스트. K2는 Kismet2 약자입니다)
UFUNCTION(BlueprintImplementableEvent, Category = "UI")
void K2_OnHPChanged(float CurrentHP, float MaxHP);
UFUNCTION(BlueprintImplementableEvent, Category = "UI")
void K2_OnTeamScoreChanged(const FText& DisplayText);
UFUNCTION(BlueprintImplementableEvent, Category = "ParcelUI")
void K2_OnRemainingTimeChanged(const FText& DisplayText, float RawRemainingTime);
UFUNCTION(BlueprintImplementableEvent, Category = "ParcelUI")
void K2_OnCrosshairStateChanged(bool bCanInteract, const FText& InteractionPrompt);
UFUNCTION(BlueprintImplementableEvent, Category = "ParcelUI")
void K2_OnCarriedBoxInfoChanged(bool bIsCarrying, const FText& BoxTypeName, const FText& DestinationText, FGameplayTag BoxTypeTag);
UFUNCTION(BlueprintImplementableEvent, Category = "ParcelUI")
void K2_OnComboChanged(int32 NewComboCount, const FText& DisplayText);
private:
UFUNCTION()
void HandleOnTeamScoreChanged(int32 NewTeamScore);
UFUNCTION()
void HandleOnComboChanged(int32 NewComboCount);
// [Timestamp] 핸들러(종료 시각)
UFUNCTION()
void HandleOnExpirationTimeChanged(float NewExpirationTime);
UFUNCTION()
void HandleOnInteractionFocusChanged(AActor* NewFocusedActor);
UFUNCTION()
void HandleOnCarriedBoxChanged(ADeliveryBox* NewCarriedBox);
// [Timestamp] (남은 시간)
void UpdateLocalTimer();
// 안전한 접근을 위해 캐싱
UPROPERTY()
TWeakObjectPtr<AParcelGameState> CachedGameState;
// 안전장치용 타이머 핸들
FTimerHandle RetryBindTimerHandle;
// [Timestamp] UI 자체 로컬 타이머 핸들 및 종료 시간 저장 변수
FTimerHandle UILocalTimerHandle;
float CachedExpirationTime = 0.0f;
};
void UParcelHUDWidget::TryBindUIEvents()
{
bool bInteractionBound = false;
bool bHealthBound = false;
bool bCarryBound = false;
bool bComboBound = false;
// GameState와 TeamScore 바인딩
if (!CachedGameState.IsValid())
{
CachedGameState = Cast<AParcelGameState>(GetWorld()->GetGameState());
if (CachedGameState.IsValid())
{
// Todo : 디커플링을 위해서 FindComponentByClass를 사용했습니다. 배포 버전을 만들 때 게터로 리팩토링이 필요합니다.
UTeamScoreComponent* TeamScoreComp = CachedGameState->FindComponentByClass<UTeamScoreComponent>();
if (TeamScoreComp)
{
// 이벤트 바인딩
TeamScoreComp->OnTeamScoreChanged.RemoveDynamic(this, &UParcelHUDWidget::HandleOnTeamScoreChanged);
TeamScoreComp->OnTeamScoreChanged.AddDynamic(this, &UParcelHUDWidget::HandleOnTeamScoreChanged);
HandleOnTeamScoreChanged(TeamScoreComp->GetTeamScore());
// 콤보 시스템 바인딩
TeamScoreComp->OnComboChanged.RemoveDynamic(this, &UParcelHUDWidget::HandleOnComboChanged);
TeamScoreComp->OnComboChanged.AddDynamic(this, &UParcelHUDWidget::HandleOnComboChanged);
HandleOnComboChanged(TeamScoreComp->GetComboCount());
// 라운드 만료 시간 바인딩
TeamScoreComp->OnRemainingTimeChanged.RemoveDynamic(this, &UParcelHUDWidget::HandleOnExpirationTimeChanged);
TeamScoreComp->OnRemainingTimeChanged.AddDynamic(this, &UParcelHUDWidget::HandleOnExpirationTimeChanged);
HandleOnExpirationTimeChanged(TeamScoreComp->GetRemainingTime());
bComboBound = true;
}
}
}
// 2. PlayerState 바인딩(삭제)
// 3. 컴포넌트 바인딩
if (APawn* OwningPawn = GetOwningPlayerPawn())
{
// 상호작용
if (UParcelInteractionComponent* InteractComp = OwningPawn->FindComponentByClass<UParcelInteractionComponent>())
{
InteractComp->OnFocusChanged.RemoveDynamic(this, &UParcelHUDWidget::HandleOnInteractionFocusChanged);
InteractComp->OnFocusChanged.AddDynamic(this, &UParcelHUDWidget::HandleOnInteractionFocusChanged);
HandleOnInteractionFocusChanged(InteractComp->GetCurrentFocusedActor());
bInteractionBound = true;
}
// 체력
if (UHealthComponent* HealthComp = OwningPawn->FindComponentByClass<UHealthComponent>())
{
HealthComp->OnHPChanged.RemoveDynamic(this, &UParcelHUDWidget::K2_OnHPChanged);
HealthComp->OnHPChanged.AddDynamic(this, &UParcelHUDWidget::K2_OnHPChanged);
K2_OnHPChanged(HealthComp->GetHP(), HealthComp->GetMaxHP());
bHealthBound = true;
}
// 운반
if (UCharacterCarryComponent* CarryComp = OwningPawn->FindComponentByClass<UCharacterCarryComponent>())
{
CarryComp->OnCarriedBoxChanged.RemoveDynamic(this, &UParcelHUDWidget::HandleOnCarriedBoxChanged);
CarryComp->OnCarriedBoxChanged.AddDynamic(this, &UParcelHUDWidget::HandleOnCarriedBoxChanged);
HandleOnCarriedBoxChanged(CarryComp->GetCarriedBox());
bCarryBound = true;
}
}
// 4. 멀티플레이 안전장치
if (CachedGameState.IsValid() && bInteractionBound && bHealthBound && bCarryBound && bComboBound)
{
GetWorld()->GetTimerManager().ClearTimer(RetryBindTimerHandle);
INGAMEHUD_LOG(Log, TEXT("[UI] 모든 인게임 HUD 요소가 안전하게 완전 결합되었습니다."));
}
else
{
if (!RetryBindTimerHandle.IsValid() && GetWorld())
{
GetWorld()->GetTimerManager().SetTimer(RetryBindTimerHandle, this, &UParcelHUDWidget::TryBindUIEvents, 0.1f, true);
INGAMEHUD_LOG(Warning, TEXT("[UI] 일부 액터 복제 대기 중. 0.1초 후 결합을 재시도합니다."));
}
}
}
void UParcelHUDWidget::HandleOnComboChanged(int32 NewComboCount)
{
if (NewComboCount > 0 && CachedGameState.IsValid())
{
UTeamScoreComponent* TeamScoreComp = CachedGameState->FindComponentByClass<UTeamScoreComponent>();
if (TeamScoreComp)
{
float CurrentMultiplier = TeamScoreComp->GetComboMultiplier();
int32 BonusPercent = FMath::RoundToInt((CurrentMultiplier - 1.0f) * 100.f);
FText FormattedText;
if (BonusPercent > 0)
{
FormattedText = FText::Format(
FText::FromString(TEXT("연속 배송 성공! {0} Combo! (+{1}% 점수 보너스!)")),
FText::AsNumber(NewComboCount),
FText::AsNumber(BonusPercent)
);
}
else
{
FormattedText = FText::Format(
FText::FromString(TEXT("연속 배송 성공! {0} Combo!")),
FText::AsNumber(NewComboCount)
);
}
K2_OnComboChanged(NewComboCount, FormattedText);
return;
}
}
K2_OnComboChanged(0, FText::GetEmpty());
}
3. 블루프린트 InGameHUD에 적용
Animation을 만들어서 콤보 텍스트가 1초간 선명하게 출력되다가 1초에 걸쳐 Fade Out 연출 효과를 구현했고, 만약 콤보 도중 콤보가 계속 이어진다면 재생중인 애니메이션을 즉시 멈추고 다시 처음부터 재생되도록 이벤트 노드를 구성했다.

[2. 캐릭터 현재 상태 시각화]
플레이어가 현재 공중에 떠 있는지(InAir), 기절해서 구르는 중인지 (Ragdoll), 혹은 지쳐서 헉헉대고 있는지 (Exhausted) 등의 상태를 Gameplay Tag 기반으로 감시해서 좌측 하단에 아바타나 아이콘 이미지로 실시간 스위칭해 주는 기믹을 적용해보자.
현재 AParcelCharacter 코드를 보니 이미 private: 구역에 CharacterStateTags라는 복제 변수 창고와 OnRep_CharacterStateTags()라는 통로를 뚫어놓아 있는 상태임. 따라서 우선적으로 캐릭터 클래스에 UI 알림용 델리게이트를 달고 최상위 HUD 위젯과 연결했음.
1. PlayerStateComponent에 상태를 전달할 수 있는 델리게이트를 열어주기.
PlayerStateComponent에 멀티캐스트 델리게이트를 달고, OnRep 시점에 뿜어주도록 한 뒤, HUD 위젯이 캐릭터가 아닌 이 컴포넌트를 바라보게 조율함.
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnCharacterStateTagsChangedSignature, const FGameplayTagContainer&, ActiveTags);
UPROPERTY(BlueprintAssignable, Category = "Character|State")
FOnCharacterStateTagsChangedSignature OnCharacterStateTagsChanged;
void UParcelPlayerStateComponent::OnRep_CharacterStateTags()
{
// Todo : 원격 클라이언트 기기에서 서버의 상태 복제를 받았을 때 로그 및 애니메이션 연동 처리 지원
PLAYERSTATECOMP_LOG(Log, TEXT("[%s] 플레이어 상태 태그 컨테이너 동기화 완료: %s"),
GetOwner() ? *GetOwner()->GetName() : TEXT("None"), *CharacterStateTags.ToString());
if (OnCharacterStateTagsChanged.IsBound())
{
OnCharacterStateTagsChanged.Broadcast(CharacterStateTags);
}
}
2. HUDWidget에 연동
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "GameplayTagContainer.h"
#include "ParcelHUDWidget.generated.h"
class AParcelGameState;
class ADeliveryBox;
/**
* UParcelWidget
* 인게임 HUD 요소의 이벤트 바인딩하고 관리하는 베이스 위젯
* 담당자 : JYW
*/
UCLASS()
class PARCEL_KNIGHT_API UParcelHUDWidget : public UUserWidget
{
GENERATED_BODY()
protected:
// 위젯의 BeginPlay 함수
virtual void NativeConstruct() override;
// 로컬 플레이어와 데이터를 바인딩
void TryBindUIEvents();
// WBP에서 사용할 수 있도록 열어두는 이벤트 (델리게이트 -> 브로드캐스트. K2는 Kismet2 약자입니다)
UFUNCTION(BlueprintImplementableEvent, Category = "UI")
void K2_OnHPChanged(float CurrentHP, float MaxHP);
UFUNCTION(BlueprintImplementableEvent, Category = "UI")
void K2_OnTeamScoreChanged(const FText& DisplayText);
UFUNCTION(BlueprintImplementableEvent, Category = "ParcelUI")
void K2_OnRemainingTimeChanged(const FText& DisplayText, float RawRemainingTime);
UFUNCTION(BlueprintImplementableEvent, Category = "ParcelUI")
void K2_OnCrosshairStateChanged(bool bCanInteract, const FText& InteractionPrompt);
UFUNCTION(BlueprintImplementableEvent, Category = "ParcelUI")
void K2_OnCarriedBoxInfoChanged(bool bIsCarrying, const FText& BoxTypeName, const FText& DestinationText, FGameplayTag BoxTypeTag);
UFUNCTION(BlueprintImplementableEvent, Category = "ParcelUI")
void K2_OnComboChanged(int32 NewComboCount, const FText& DisplayText);
UFUNCTION(BlueprintImplementableEvent, Category = "ParcelUI")
void K2_OnCharacterStateChanged(const FGameplayTagContainer& ActiveTags);
private:
UFUNCTION()
void HandleOnTeamScoreChanged(int32 NewTeamScore);
UFUNCTION()
void HandleOnComboChanged(int32 NewComboCount);
// [Timestamp] 핸들러(종료 시각)
UFUNCTION()
void HandleOnExpirationTimeChanged(float NewExpirationTime);
UFUNCTION()
void HandleOnInteractionFocusChanged(AActor* NewFocusedActor);
UFUNCTION()
void HandleOnCarriedBoxChanged(ADeliveryBox* NewCarriedBox);
UFUNCTION()
void HandleOnCharacterStateChanged(const FGameplayTagContainer& ActiveTags);
// [Timestamp] (남은 시간)
void UpdateLocalTimer();
// 안전한 접근을 위해 캐싱
UPROPERTY()
TWeakObjectPtr<AParcelGameState> CachedGameState;
// 안전장치용 타이머 핸들
FTimerHandle RetryBindTimerHandle;
// [Timestamp] UI 자체 로컬 타이머 핸들 및 종료 시간 저장 변수
FTimerHandle UILocalTimerHandle;
float CachedExpirationTime = 0.0f;
};
#include "UI/ParcelHUDWidget.h"
#include "ParcelLog.h"
#include "GameFramework/GameStateBase.h"
#include "Core/ParcelGameState.h"
#include "Core/TeamScoreComponent.h"
#include "Character/ParcelInteractionComponent.h"
#include "Core/HealthComponent.h"
#include "Character/CharacterCarryComponent.h"
#include "Character/ParcelCharacter.h"
#include "Character/ParcelPlayerStateComponent.h"
#include "Delivery/DeliveryBox.h"
#include "GameFramework/Pawn.h"
DEFINE_LOG_CATEGORY(LogInGameHUD);
void UParcelHUDWidget::NativeConstruct()
{
Super::NativeConstruct();
TryBindUIEvents();
}
void UParcelHUDWidget::TryBindUIEvents()
{
bool bInteractionBound = false;
bool bHealthBound = false;
bool bCarryBound = false;
bool bComboBound = false;
bool bCharacterStateBound = false;
// GameState와 TeamScore 바인딩
if (!CachedGameState.IsValid())
{
CachedGameState = Cast<AParcelGameState>(GetWorld()->GetGameState());
if (CachedGameState.IsValid())
{
// Todo : 디커플링을 위해서 FindComponentByClass를 사용했습니다. 배포 버전을 만들 때 게터로 리팩토링이 필요합니다.
UTeamScoreComponent* TeamScoreComp = CachedGameState->FindComponentByClass<UTeamScoreComponent>();
if (TeamScoreComp)
{
// 이벤트 바인딩
TeamScoreComp->OnTeamScoreChanged.RemoveDynamic(this, &UParcelHUDWidget::HandleOnTeamScoreChanged);
TeamScoreComp->OnTeamScoreChanged.AddDynamic(this, &UParcelHUDWidget::HandleOnTeamScoreChanged);
HandleOnTeamScoreChanged(TeamScoreComp->GetTeamScore());
// 콤보 시스템 바인딩
TeamScoreComp->OnComboChanged.RemoveDynamic(this, &UParcelHUDWidget::HandleOnComboChanged);
TeamScoreComp->OnComboChanged.AddDynamic(this, &UParcelHUDWidget::HandleOnComboChanged);
HandleOnComboChanged(TeamScoreComp->GetComboCount());
// 라운드 만료 시간 바인딩
TeamScoreComp->OnRemainingTimeChanged.RemoveDynamic(this, &UParcelHUDWidget::HandleOnExpirationTimeChanged);
TeamScoreComp->OnRemainingTimeChanged.AddDynamic(this, &UParcelHUDWidget::HandleOnExpirationTimeChanged);
HandleOnExpirationTimeChanged(TeamScoreComp->GetRemainingTime());
bComboBound = true;
}
}
}
// 2. PlayerState 바인딩(삭제)
// 3. 컴포넌트 바인딩
if (APawn* OwningPawn = GetOwningPlayerPawn())
{
if (AParcelCharacter* ParcelChar = Cast<AParcelCharacter>(OwningPawn))
{
if (UParcelPlayerStateComponent* StateComp = ParcelChar->GetParcelPlayerStateComponent())
{
StateComp->OnCharacterStateTagsChanged.RemoveDynamic(this, &UParcelHUDWidget::HandleOnCharacterStateChanged);
StateComp->OnCharacterStateTagsChanged.AddDynamic(this, &UParcelHUDWidget::HandleOnCharacterStateChanged);
// 진입 시점의 최초 캐릭터 상태 태그 강제 초기화
HandleOnCharacterStateChanged(StateComp->GetCharacterStateTags());
bCharacterStateBound = true;
}
}
// 상호작용
if (UParcelInteractionComponent* InteractComp = OwningPawn->FindComponentByClass<UParcelInteractionComponent>())
{
InteractComp->OnFocusChanged.RemoveDynamic(this, &UParcelHUDWidget::HandleOnInteractionFocusChanged);
InteractComp->OnFocusChanged.AddDynamic(this, &UParcelHUDWidget::HandleOnInteractionFocusChanged);
HandleOnInteractionFocusChanged(InteractComp->GetCurrentFocusedActor());
bInteractionBound = true;
}
// 체력
if (UHealthComponent* HealthComp = OwningPawn->FindComponentByClass<UHealthComponent>())
{
HealthComp->OnHPChanged.RemoveDynamic(this, &UParcelHUDWidget::K2_OnHPChanged);
HealthComp->OnHPChanged.AddDynamic(this, &UParcelHUDWidget::K2_OnHPChanged);
K2_OnHPChanged(HealthComp->GetHP(), HealthComp->GetMaxHP());
bHealthBound = true;
}
// 운반
if (UCharacterCarryComponent* CarryComp = OwningPawn->FindComponentByClass<UCharacterCarryComponent>())
{
CarryComp->OnCarriedBoxChanged.RemoveDynamic(this, &UParcelHUDWidget::HandleOnCarriedBoxChanged);
CarryComp->OnCarriedBoxChanged.AddDynamic(this, &UParcelHUDWidget::HandleOnCarriedBoxChanged);
HandleOnCarriedBoxChanged(CarryComp->GetCarriedBox());
bCarryBound = true;
}
}
// 4. 멀티플레이 안전장치
if (CachedGameState.IsValid() && bInteractionBound && bHealthBound && bCarryBound && bComboBound && bCharacterStateBound)
{
GetWorld()->GetTimerManager().ClearTimer(RetryBindTimerHandle);
INGAMEHUD_LOG(Log, TEXT("[UI] 모든 인게임 HUD 요소가 안전하게 완전 결합되었습니다."));
}
else
{
if (!RetryBindTimerHandle.IsValid() && GetWorld())
{
GetWorld()->GetTimerManager().SetTimer(RetryBindTimerHandle, this, &UParcelHUDWidget::TryBindUIEvents, 0.1f, true);
INGAMEHUD_LOG(Warning, TEXT("[UI] 일부 액터 복제 대기 중. 0.1초 후 결합을 재시도합니다."));
}
}
}
// 핸들러 함수
void UParcelHUDWidget::HandleOnTeamScoreChanged(int32 NewTeamScore)
{
FText FormattedText = FText::Format(FText::FromString(TEXT("팀 점수 : {0}")), FText::AsNumber(NewTeamScore));
K2_OnTeamScoreChanged(FormattedText);
}
void UParcelHUDWidget::HandleOnComboChanged(int32 NewComboCount)
{
if (NewComboCount > 0 && CachedGameState.IsValid())
{
UTeamScoreComponent* TeamScoreComp = CachedGameState->FindComponentByClass<UTeamScoreComponent>();
if (TeamScoreComp)
{
float CurrentMultiplier = TeamScoreComp->GetComboMultiplier();
int32 BonusPercent = FMath::RoundToInt((CurrentMultiplier - 1.0f) * 100.f);
FText FormattedText;
if (BonusPercent > 0)
{
FormattedText = FText::Format(
FText::FromString(TEXT("연속 배송 성공! {0} Combo! (+{1}% 점수 보너스!)")),
FText::AsNumber(NewComboCount),
FText::AsNumber(BonusPercent)
);
}
else
{
FormattedText = FText::Format(
FText::FromString(TEXT("연속 배송 성공! {0} Combo!")),
FText::AsNumber(NewComboCount)
);
}
K2_OnComboChanged(NewComboCount, FormattedText);
return;
}
}
K2_OnComboChanged(0, FText::GetEmpty());
}
void UParcelHUDWidget::HandleOnExpirationTimeChanged(float NewExpirationTime)
{
if (GetWorld() && NewExpirationTime > 0.0f)
{
CachedExpirationTime = GetWorld()->GetTimeSeconds() + NewExpirationTime;
GetWorld()->GetTimerManager().ClearTimer(UILocalTimerHandle);
GetWorld()->GetTimerManager().SetTimer(UILocalTimerHandle, this, &UParcelHUDWidget::UpdateLocalTimer, 0.2f, true);
}
}
void UParcelHUDWidget::HandleOnInteractionFocusChanged(AActor* NewFocusedActor)
{
if (NewFocusedActor)
{
FText PromptText = FText::FromString(TEXT("E 키를 눌러 상호작용"));
K2_OnCrosshairStateChanged(true, PromptText);
}
else
{
K2_OnCrosshairStateChanged(false, FText::GetEmpty());
}
}
void UParcelHUDWidget::HandleOnCarriedBoxChanged(ADeliveryBox* NewCarriedBox)
{
if (!NewCarriedBox)
{
K2_OnCarriedBoxInfoChanged(false, FText::GetEmpty(), FText::GetEmpty(), FGameplayTag());
return;
}
FBoxData CarriedBoxData = NewCarriedBox->GetBoxData();
FText BoxNameText = FText::FromString(CarriedBoxData.DisplayName);
FText FormattedName = FText::Format(
FText::FromString(TEXT("{0} ({1}kg)")),
BoxNameText,
FText::AsNumber(CarriedBoxData.Weight)
);
FText DestinationText = FText::FromString(TEXT("목적지 : 미지정 구역"));
if (CarriedBoxData.TargetZoneTag.IsValid())
{
FString ZoneString = CarriedBoxData.TargetZoneTag.ToString();
ZoneString.ReplaceInline(TEXT("Delivery."), TEXT(""));
ZoneString.ReplaceInline(TEXT("Zone."), TEXT(""));
DestinationText = FText::Format(
FText::FromString(TEXT("목적지 : {0} 구역")),
FText::FromString(ZoneString)
);
}
K2_OnCarriedBoxInfoChanged(true, FormattedName, DestinationText, CarriedBoxData.BoxTypeTag);
}
void UParcelHUDWidget::UpdateLocalTimer()
{
float CurrentWorldTime = GetWorld()->GetTimeSeconds();
float RawRemainingTime = CachedExpirationTime - CurrentWorldTime;
if (RawRemainingTime <= 0.0f)
{
GetWorld()->GetTimerManager().ClearTimer(UILocalTimerHandle);
RawRemainingTime = 0.0f;
}
// Min Sec 쪼개기
int32 TotalSeconds = FMath::FloorToInt(RawRemainingTime);
int32 Minutes = TotalSeconds / 60;
int32 Seconds = TotalSeconds % 60;
// 언리얼 내부 함수를 사용해서 2자리 수 맞추기
FNumberFormattingOptions TwoDigitOptions;
TwoDigitOptions.MinimumIntegralDigits = 2;
FText MinText = FText::AsNumber(Minutes, &TwoDigitOptions);
FText SecText = FText::AsNumber(Seconds, &TwoDigitOptions);
// Format Text 문자열 조립
FText FormattedTime = FText::Format(
FText::FromString(TEXT("남은시간 {0} : {1}")),
MinText,
SecText
);
K2_OnRemainingTimeChanged(FormattedTime, RawRemainingTime);
}
void UParcelHUDWidget::HandleOnCharacterStateChanged(const FGameplayTagContainer& ActiveTags)
{
K2_OnCharacterStateChanged(ActiveTags);
}
3. 블루프린트 이벤트 그래프 작업
'Widget Switcher'를 캔버스 패널에 적절하게 배치하고, 변수 선언을 한 후에 이벤트 그래프에서 작업.

*개인 프로젝트 및 개인 공부*
1. 영구 존속 레이어의 심장이 될 커스텀 게임 인스턴스와 전역 경영 루프를 돌릴 시간 제어 서브시스템 구현
2. 'Save Game' 인프라를 전역 매니저 레이어 구축. 경영 RPG 및 로그라이크 게임에서 세이브 시스템은 월드(World)나 레벨 레벨에 묶이면 절대 안 되며, 프로세스 전역 수명 주기를 쥐고 있는 GameInstance 영역에서 모든 서브시스템의 알맹이들을 수집해 디스크에 굽고 복원하는 마스터 컨트롤러 형태로 존재해야 한다. 따라서 SaveGame 코드와 SaveGameSubsystem 코드를 구현.
3. Readme에 '클래스 다이어그램', '폴더 구조', '게임 루프', '코드 작성 주의사항' 업데이트 후 추가하여 항상 확인하면서 진행 상황 파악할 수 있도록 작성.
4. 길드 골드, 명성, 설비 레벨, 용병 명단"이라는 중앙 데이터가 없으면 코드를 짤 수 없는 구조이므로, 시간(Time) 서브시스템과 세이브(Save) 서브시스템에 맞물려 기능을 수용할 준비를 하는 GuildSubsystem을 구현함.
5. GuildTypes 신설: 용병의 고유 정보와 상태를 정의하는 구조체 마스터 파일을 만듬. ENUM을 사용하지 않고, FGameplayTag를 Key로 사용하는 고성능 TMap 구조로 구축하였음.
6. GuildSubsystem 신설 : 길드의 모든 자금 줄과 정산 루프를 총괄하는 메인 서브시스템. UI가 변화를 감지하여 리드로잉할 수 있도록 Assignable 델리게이트를 장착하였음. 이후 길드 정보를 Save할 수 있도록 SaveGame에 UPROPERTY로 추가.
7. QuestTypes, QuestSubsystem 신설 : 데이터 테이블 및 데이터 에셋에서 긁어올 정적 구조체와, 메모리에서 실시간으로 변할 실적 장부 구조체를 QuestTypes로 정의함. 퀘스트의 수락, 실적 카운팅, 보상 지급을 총괄하는 전역 매니저는 QuestSubsystem으로 정의하고 신설하였음. 서브시스템에서는 UI 트래커 위젯이 신호를 받아 화면을 리드로잉할 수 있도록 델리게이트를 개설 포인터 변수들의 수명을 if 내부로 격리하고, 보상 지급 시 형제 서브시스템인 UDgaGuildSubsystem을 추적하는 로직을 구현. 이후 SaveGame 코드 파일에는 세이브 가방과 마스터 컨트롤러 소스 파일 2종에 퀘스트 도메인 직렬화 코드를 이식하였음.
8. 경쟁 길드의 상태, 명성, 골드, 용병단 규모를 정의하는 데이터 명세 구조체 RivalTypes, 매일 밤 경쟁 길드들이 주사위를 굴려 독자적으로 명성을 쌓고, 돈을 벌며, 용병을 영입/방출하는 가벼운 경영 난수 시뮬레이션을 처리하는 RivalSubsystem 신설 후, Save 기능과 SaveSubsystem에 연동.
9. 코드 점검 9단계 프로토콜을 사용하여 코드를 한번 더 점검하고, 오류를 수정하였으며, 추가로 변조를 통한 자원 치트 등을 막기 위해 난수 변조를 사용하여 플레이어 길드의 Data를 암호화하였음.
10. 이후 feat/Subsystem의 1차 작업을 마무리하고, 다음 브랜치로 이동. UI FrameWork를 우선적으로 만들기 위해 feat/Framework-UI 브랜치를 생성. 메인 메뉴에서 버튼을 눌렀을 때 화면이 암전되며 마을로 워프하고, 로그 창에 세이브 데이터 통합 완료가 찍히는 검증선이 잡혀야 개발 진척과 테스가 수월해지기 때문임.
==============
*오늘의 총평*
팀프로젝트도 알차게 진행했고, 개인 프로젝트도 알차게 진행했다. 특히 개인 프로젝트는 서브시스템 및 전역 매니저 기반을 튼튼하게 마련해놓았다.