Today I Learned |
Unreal Engine 5.7|C++|GAS|StateTree|Boss|데이터 아키텍처
오늘은 팀원 브랜치 머지 후 코드를 직접 검증하고, 보스 캐릭터 베이스 클래스 2종을 직접 설계·구현했습니다. Groggy 데이터 관리 방식은 설계 문서가 아닌 실제 코드를 읽어 결정했습니다.
ARetrieveBossCharacter + UBossPhaseComponent 직접 설계·구현보스 클래스를 작성하기 전에 어디서 상속받을지 결정하기 위해 헤더 파일들을 직접 읽었습니다.
ARetrieveCharacter
└─ ARetrieveCombatCharacter (HealthComponent, HandleDeathStarted 가상 기반)
└─ ARetrieveEnemyCharacter (OwnedASC, EnemyCombatComponent, PatternCounterComponent)
└─ ARetrieveBossCharacter ← 신규
ARetrieveEnemyCharacter를 상속받으면 ASC·HealthComponent·EnemyCombatComponent를 추가 작업 없이 재사용할 수 있다는 것을 확인했습니다. 새로 추가한 것은 UBossPhaseComponent와 DT_BossStats 참조 두 가지뿐이었습니다.
HandleDeathStarted — Super 호출 금지 이유 직접 확인보스 사망 처리를 작성하면서 ARetrieveEnemyCharacter::HandleDeathStarted 구현을 직접 읽어봤습니다. 내부적으로 State.Enemy.Dead 태그를 적용하고 GameplayEvent.Enemy.Die를 전송하고 있었습니다.
보스 StateTree는 State.Boss.Dead와 GameplayEvent.Boss.Die를 기준으로 Dead 분기에 진입하도록 설계되어 있으므로, Super를 호출하면 보스가 일반 몬스터 사망 경로로 빠진다는 것을 확인했습니다. Super 호출을 막고 보스 전용 구현으로 대체했습니다.
void ARetrieveBossCharacter::HandleDeathStarted(AActor* OwningActor)
{
// Super(EnemyCharacter) 호출 금지:
// → State.Enemy.Dead 태그 + GameplayEvent.Enemy.Die 전송 경로를 타게 됨
// 보스는 State.Boss.Dead / GameplayEvent.Boss.Die 경로를 사용해야 합니다.
if (OwnedASC)
{
OwnedASC->AddLooseGameplayTag(RetrieveGameplayTags::State_Boss_Dead);
}
// ...
}
FBossStatsRow 헤더를 읽다가 UnlockElementTag 필드를 발견했습니다. 가디언은 속성 해금 태그를 가지고, 여왕은 가지지 않는다는 기획을 확인하고 이 필드의 유효 여부만으로 분기하는 것으로 결정했습니다.
const FBossStatsRow* Row = GetBossStatsRow();
if (Row->UnlockElementTag.IsValid())
{
// 가디언: 속성 해금 파이프라인
FRetrieveGuardianDefeatedPayload Payload;
Payload.GuardianElement = Row->UnlockElementTag;
UGameplayMessageSubsystem::Get(this).BroadcastMessage(
RetrieveGameplayTags::Channel_Quest_GuardianDefeated, Payload);
}
else
{
// 여왕: 엔딩 트리거
UGameplayMessageSubsystem::Get(this).BroadcastMessage(
RetrieveGameplayTags::Channel_Game_QueenDefeated, DiedPayload);
}
UBossPhaseComponent 설계 — 페이즈 전환 책임 분리페이즈 전환 로직을 ARetrieveBossCharacter 안에 두면 사망 처리·데이터 초기화·GameplayEvent 전송까지 한 클래스가 모두 맡게 됩니다. HP 감시와 페이즈 전환만 담당하는 컴포넌트로 분리했습니다.
InitializeComponents에서 HealthComponent의 OnHealthChanged 델리게이트를 구독하고, 임계값 도달 시 MonsterDataRow 교체와 GameplayEvent 전송을 처리합니다.
페이즈 행 이름 규칙은 다음과 같이 정했습니다.
규칙: {BossStatsRowName}_Phase{N}
예) "Boss_Fire" → "Boss_Fire_Phase2"
void UBossPhaseComponent::TransitionToNextPhase()
{
++CurrentPhase;
const FName NewDataRow = FName(
*(BossStatsRowName.ToString() + FString::Printf(TEXT("_Phase%d"), CurrentPhase)));
Boss->UpdateMonsterDataRow(NewDataRow); // EnemyCombatComponent 패턴 슬롯 재초기화
UAbilitySystemBlueprintLibrary::SendGameplayEventToActor(
Boss, RetrieveGameplayTags::GameplayEvent_Boss_PhaseTransition, EventData);
}
- 보스는
State.Boss.Dead/GameplayEvent.Boss.Die를 사용합니다.Super::HandleDeathStarted()호출 시 일반 몬스터 사망 경로(State.Enemy.Dead)를 타므로 반드시 막아야 합니다.- 가디언/여왕 분기는
FBossStatsRow.UnlockElementTag.IsValid()한 줄로 충분합니다.- 페이즈 데이터 행 이름 규칙:
{BossStatsRowName}_Phase{N}.- 페이즈 전환 책임은
UBossPhaseComponent로 분리해 오너 클래스가 HP 감시 로직을 직접 가지지 않도록 했습니다.
기상님 브랜치를 머지한 뒤 코드 일부가 이상해 보여 오염을 의심했습니다. 파일을 하나씩 열어보는 대신 기상님 마지막 커밋 해시를 기준으로 diff를 냈습니다.
git diff 3093dae097 HEAD -- <대상 파일들>
결과는 빈 출력이었습니다. 현재 HEAD가 기상님 마지막 커밋 상태와 완전히 동일하다는 의미였고, 의심했던 코드는 오염이 아니라 정상이었음을 확인했습니다.
CompactInvalidAttackTokens와 PastSlot.Reset()이 겹쳐 보였던 이유ShiftSlotExplicit 안에 두 코드가 함께 있어 역할이 중복되는 것이 아닌가 의아했습니다. FRing 구조체를 직접 읽어보니 원인이 명확해졌습니다.
struct FRing
{
TArray<TWeakObjectPtr<AActor>> Slots; // 위치 점유
TArray<TWeakObjectPtr<AActor>> AttackTokens; // 공격 권한
};
| 코드 | 대상 배열 | 역할 |
|---|---|---|
CompactInvalidAttackTokens(Ring) | AttackTokens | GC로 소멸된 Actor의 약참조 제거 |
Ring.Slots[PastSlot].Reset() | Slots | 이동 전 Requester의 기존 위치 점유 해제 |
두 배열이 완전히 독립적이었습니다. 구조체를 읽기 전에 코드를 건드렸다면 멀쩡한 로직을 잘못 제거할 뻔했습니다.
FBossStatsRow(GroggyDuration + GroggyCooldown 고정)와 FMonsterPatternRow + FMonsterDataRow(패턴별 지속 시간 + 몬스터별 재진입 대기 시간) 중 어느 방식으로 통일할지 결정해야 했습니다. 설계 문서만으로는 판단이 어려워 실제로 Groggy를 처리하는 PatternCounterComponent.cpp를 직접 읽었습니다.
// PatternCounterComponent::ApplyCounterResult()
const float GroggyDur = ActivePatternData.GroggyDuration; // 이미 PatternRow에서 읽고 있었음
GroggyEvent.EventMagnitude = ActivePatternData.GroggyDuration;
// TODO (B6-a): DT_MonsterData.GroggyCooldown을 읽어서 쿨다운 설정
GroggyCooldownExpiry = Now + GroggyDur + GroggyCooldown;
코드가 이미 PatternRow 방식으로 동작하고 있었고, SetGroggyCooldown() 메서드도 이미 준비되어 있었습니다. TODO 주석도 MonsterDataRow를 가리키고 있었습니다. FBossStatsRow에 Groggy 필드가 있더라도 이 컴포넌트가 그 테이블을 읽지 않으므로, 값을 채워도 게임에 반영되지 않는 죽은 데이터였습니다.
✅ GroggyDuration → FMonsterPatternRow (패턴별 지속 시간)
✅ GroggyCooldown → FMonsterDataRow (몬스터별 재진입 대기 시간)
❌ FBossStatsRow의 GroggyDuration / GroggyCooldown → 제거
FBossStatsRow에서 두 필드를 제거하고 커밋했습니다.
- 머지 후 코드 의심이 들면
git diff <커밋해시> HEAD로 먼저 확인하는 것이 가장 빠릅니다.- 역할이 겹쳐 보이는 두 코드가 있을 때는 먼저 대상 데이터 구조체를 읽는 것이 순서입니다.
- 데이터 아키텍처 결정은 설계 문서보다 실제 그 데이터를 읽는 코드가 더 확실한 근거입니다.
PatternCounterComponent가 이미PatternRow.GroggyDuration을 읽고 있다는 것을 확인한 뒤 방향을 결정했습니다.
7기 Team2 Final Project — feature/enemy-0609