Sparta Unreal 부트캠프 85일차

정찬호·2026년 4월 2일

Challenge 분반 수업

지금까지는 InitializeAbilitySystem()에서 OwnerActor와 AvatarActor를 this로 해서 가지는 액터와 적용되는 액터를 같게 했는데, 이번에는 좀 다르게 변경됐습니다.
GameFramework/PlayerState를 사용하는 것입니다.

PlayerState를 사용하는 이유

PlayerState는 플레이어의 상태를 저장합니다.

장점설명
데이터 영속성캐릭터가 죽어도 PlayerState는 유지됨
리스폰 유지버프/디버프가 새 캐릭터에 그대로 적용
캐릭터 교체탈것, 변신 등에서 ASC 유지
네트워크 효율PlayerState 복제는 이미 최적화됨

Player(PlayerState에 저장)와 Enemy(Character에 저장)로 분리해서 구현을 진행했습니다.

대상패턴ASC 위치이유
PlayerPlayerState가 소유PlayerState리스폰 시 데이터 유지, 멀티 플레이어 대응
EnemyCharacter가 직접 소유Character사망 시 완전 제거, 단순한 생명주기

InitializeAbilitySystem() 변경
기존 코드

void AMyCharacterBase::InitializeAbilitySystem()
{
    if (AbilitySystemComponent)
    {
        AbilitySystemComponent->InitAbilityActorInfo(this, this);
        UE_LOG(LogTemp, Log, TEXT("ASC Initialized for %s"), *GetName());
    }
}

변경된 코드

void AMyCharacterBase::InitializeAbilitySystem()
{
    if (AbilitySystemComponent)
    {
        // OwnerActor = PlayerState, AvatarActor = this 로 분리
        AbilitySystemComponent->InitAbilityActorInfo(PlayerState, this);
        UE_LOG(LogTemp, Log, TEXT("ASC Initialized for %s"), *GetName());
    }
}

OwnerActor와 AvatarActor가 분리되었습니다.

MyPlayerState.h — ASC 생성 및 설정

AMyPlayerState::AMyPlayerState()
{
    AbilitySystemComponent = CreateDefaultSubobject<UAbilitySystemComponent>(
        TEXT("AbilitySystemComponent"));
    AbilitySystemComponent->SetIsReplicated(true);

    // Full 모드: Player는 모든 GE를 복제해야 합니다!
    AbilitySystemComponent->SetReplicationMode(EGameplayEffectReplicationMode::Full);

    AttributeSet = CreateDefaultSubobject<UMyAttributeSet>(TEXT("AttributeSet"));
    SetNetUpdateFrequency(100.0f);
}

PlayerState에 저장하는 ASC의 ReplicationMode는 Full로 설정합니다.
AbilitySystemComponent.h의 주석 기준 요약:

  • Minimal: 시뮬레이티드 프록시에만 최소 정보 복제. Owned ASC에는 사용 불가 → Mixed 사용
  • Mixed: 시뮬레이티드 프록시엔 최소, 소유자/오토노머스 프록시엔 전체 복제
  • Full: 모든 클라이언트에 전체 복제

CharacterBase & PlayerState 연결하기

PossessedByOnRep_PlayerState를 작성해 주어야 합니다.
AbilitySystemComponent가 있어야만 동작하는 함수들(GetAbilitySystemComponent(), InitializeAbilitySystem(), GiveDefaultAbilities)이 문제인데, PlayerCharacter가 존재할 때 PlayerState가 존재한다고 확정 지을 수 없습니다. 두 클래스의 인스턴스는 병렬적으로 생성됩니다.

  • 서버: PossessedBy()가 호출되는 시점에 PlayerState 생성이 완료되므로 여기서 InitializeAbilitySystem() 호출
  • 클라이언트: PossessedBy()는 서버 전용이므로, 클라이언트에서는 OnRep_PlayerState()에서 호출

EnemyCharacterBase 만들기

CharacterBase와 다르게 ASC를 직접 소유합니다.
AttributeSet은 별도로 분리해서 사용하는 것이 유지보수 측면에서 좋습니다. 필요한 속성 종류가 다를 수 있기 때문입니다.

UGameplayEffectExecutionCalculation을 상속한 UExecCalc_Damage에서 Enemy를 위한 Capture 구조체 수동 정의가 필요합니다.
DECLARE_ATTRIBUTE_CAPTUREDEF 매크로는 이름 충돌이 생기므로 Enemy용은 수동 정의해야 합니다.

struct FDamageStatics
{
    // 기존 Player용
    DECLARE_ATTRIBUTE_CAPTUREDEF(Defense);
    DECLARE_ATTRIBUTE_CAPTUREDEF(Health);

    // Enemy용 — 매크로 이름 충돌로 수동 정의
    FGameplayEffectAttributeCaptureDefinition EnemyDefenseDef;
    FGameplayEffectAttributeCaptureDefinition EnemyHealthDef;

    FDamageStatics()
    {
        DEFINE_ATTRIBUTE_CAPTUREDEF(UMyAttributeSet, Defense, Target, false);
        DEFINE_ATTRIBUTE_CAPTUREDEF(UMyAttributeSet, Health, Target, false);

        EnemyDefenseDef.AttributeToCapture = UEnemyAttributeSet::GetDefenseAttribute();
        EnemyDefenseDef.AttributeSource = EGameplayEffectAttributeCaptureSource::Target;
        EnemyDefenseDef.bSnapshot = false;

        EnemyHealthDef.AttributeToCapture = UEnemyAttributeSet::GetHealthAttribute();
        EnemyHealthDef.AttributeSource = EGameplayEffectAttributeCaptureSource::Target;
        EnemyHealthDef.bSnapshot = false;
    }
};

데미지 계산 로직에서 Enemy 여부 체크 시 GetSet<T>()를 활용했습니다.
이는 AbilitySystemComponent에 정의된 클래스 판별용 함수입니다.

const T* GetSet() const
{
    return (T*)GetAttributeSubobject(T::StaticClass());
}

팀프로젝트 — VGMission 시스템

1. Delegate 중복 문제 해결

MissionSubsystem에서 MissionBaseOnMissionStateChanged Delegate를 SubsystemOnMissionStateChanged에 직접 연결하려 했는데, Delegate에 Delegate를 직접 등록하는 것은 불가능합니다.
AddDynamicUFUNCTION으로 마킹된 멤버 함수에만 바인딩할 수 있기 때문입니다.

HandleMissionStateChanged() 래퍼 함수를 만들어 우회했습니다.

void UVGMissionSubsystem::RegisterMission(AVGMissionBase* Mission)
{
    if (!Mission) return;

    if (!RegisteredMissions.Contains(Mission))
    {
        RegisteredMissions.AddUnique(Mission);

        // 완료 이벤트 — 진행도 계산용
        Mission->OnMissionCompleted.AddDynamic(
            this, &UVGMissionSubsystem::OnMissionCompleted);

        // 상태 전환 이벤트 — HUD 갱신용
        // Delegate → Delegate 직접 연결 불가 → Handle 함수로 중계
        Mission->OnMissionStateChanged.AddDynamic(
            this, &UVGMissionSubsystem::HandleMissionStateChanged);
    }
}

void UVGMissionSubsystem::HandleMissionStateChanged(int32 MissionID, FGameplayTag NewStateTag)
{
    OnMissionStateChanged.Broadcast(MissionID, NewStateTag);
}

2. MissionBase Abstract 구조 확정

미션 달성 판정 로직을 자식 클래스에 위임하는 구조로 확정했습니다.
UE에서 UCLASS는 순수 가상 함수(= 0)를 지원하지 않으므로, ensureMsgf로 override 강제를 대신합니다.

// AVGMissionBase — 달성 판정 로직 자식 위임
void AVGMissionBase::OnConditionMet(AActor* Reporter)
{
    if (!HasAuthority()) return;
    if (CurrentStateTag == VigilantMissionTags::MissionCompleted) return;

    // 판정은 자식에게 위임
    if (CheckMissionCondition(Reporter))
    {
        CompleteMission();
    }
}

bool AVGMissionBase::CheckMissionCondition(AActor* Reporter)
{
    // 자식이 override 안 하면 런타임 경고
    ensureMsgf(false,
        TEXT("CheckMissionCondition must be overridden in %s"),
        *GetClass()->GetName());
    return false;
}

자식 클래스는 CheckMissionCondition()만 override하면 됩니다.

// AVGMissionPressureSequence — 발판 순서 미션
bool AVGMissionPressureSequence::CheckMissionCondition(AActor* Reporter)
{
    AVGMissionGimmickPressure* Pressure =
        Cast<AVGMissionGimmickPressure>(Reporter);
    if (!Pressure) return false;
    return CheckSequenceOrder(Pressure);
}

bool AVGMissionPressureSequence::CheckSequenceOrder(
    AVGMissionGimmickPressure* Pressure)
{
    if (Pressure->GetSequenceIndex() != CurrentSequenceIndex)
    {
        // 순서 틀림 — 모든 발판 초기화
        CurrentSequenceIndex = 0;
        for (AVGMissionGimmickBase* Gimmick : MissionGimmicks)
        {
            Gimmick->ResetGimmickState();
        }
        return false;
    }

    CurrentSequenceIndex++;
    return CurrentSequenceIndex >= MissionGimmicks.Num();
}

3. IVGMissionObjectInterface 도입

GimmickBaseItemBase에 공통으로 존재하는 SetOwnerMission(), ReportConditionMet(), GetStateTag(), SetStateTag()를 하나의 인터페이스로 추출했습니다.

MissionItem은 추후 팀원의 EquippableActor를 상속해야 하므로 단일 부모 클래스로 묶을 수 없는 상황입니다. UInterface를 활용해 다중 상속 없이 언리얼 컨벤션에 맞게 해결했습니다.

UINTERFACE()
class UVGMissionObjectInterface : public UInterface { GENERATED_BODY() };

class VIGILANT_API IVGMissionObjectInterface
{
    GENERATED_BODY()
public:
    virtual void SetOwnerMission(AVGMissionBase* InOwnerMission) = 0;
    virtual void ReportConditionMet() = 0;
    virtual FGameplayTag GetStateTag() = 0;
    virtual void SetStateTag(FGameplayTag NewStateTag) = 0;
};

MissionBase::BeginPlay()에서 Cast로 통일 처리합니다.

// BeginPlay에서 Gimmick/Item 모두 동일한 방식으로 처리
for (AVGMissionGimmickBase* Gimmick : MissionGimmicks)
{
    if (Gimmick)
    {
        Gimmick->SetOwnerMission(this);
        Gimmick->OnGimmickStateChanged.AddDynamic(
            this, &AVGMissionBase::OnGimmickStateChanged);
    }
}

4. 테스트 중 발견하고 수정한 버그들

PIE + Standalone 환경에서 테스트하면서 발견한 버그들입니다.

버그 1 — SetOwnerMission() 가드 조건 반전

// 수정 전 — OwnerMission(멤버)을 검사해서 항상 return
if (!OwnerMission) { return; }
OwnerMission = InOwnerMission; // 절대 도달 불가

// 수정 후 — 인자를 검사
if (!InOwnerMission) { return; }
OwnerMission = InOwnerMission; // 정상 할당

버그 2 — UFUNCTION 누락으로 AddDynamic 크래시

AddDynamicUFUNCTION으로 마킹된 함수만 바인딩 가능합니다. OnGimmickStateChanged, OnItemStateChangedUFUNCTION() 누락으로 PIE 시작 시 ensure 크래시가 발생했습니다.

// 수정 전
virtual void OnGimmickStateChanged(AVGMissionGimmickBase* Gimmick, FGameplayTag Tag);

// 수정 후
UFUNCTION()
virtual void OnGimmickStateChanged(AVGMissionGimmickBase* Gimmick, FGameplayTag Tag);

버그 3 — Lever Toggle()에서 비활성 시에도 ReportConditionMet() 호출

// 수정 전 — 항상 호출
SetGimmickState(Active or Inactive);
ReportConditionMet(); // OFF 시에도 미션 조건 트리거

// 수정 후 — 활성화 시에만 보고
if (IsActivated()) {
    SetStateTag(GimmickInactive);
} else {
    SetStateTag(GimmickActive);
    ReportConditionMet(); // ON 시에만 보고
}

버그 4 — FGameplayTag로 switch문 사용 불가

FGameplayTag는 정수형이 아니므로 switch문에 사용할 수 없습니다. if-else로 변경했습니다.

// 수정 전 — 컴파일 에러
switch (GimmickStateTag) { case VigilantMissionTags::GimmickCompleted: ... }

// 수정 후
if (GimmickStateTag == VigilantMissionTags::GimmickCompleted)
    Color = FLinearColor::Black;
else if (GimmickStateTag == VigilantMissionTags::GimmickActive)
    Color = FLinearColor(0.f, 1.f, 1.f);

버그 5 — 미션 클리어 시 Gimmick 색상 미변경

OnRep_CurrentStateTag()에서 Gimmick에 MissionCompleted 태그를 넣고 있었는데, OnRep_GimmickStateTag()GimmickCompleted를 비교하므로 색상 변경 분기에 진입하지 못했습니다.

또한 OnRep_CurrentStateTag()는 클라이언트에서도 호출되는데, SetStateTag() 내부의 HasAuthority() 체크로 클라이언트에서는 실행이 막혔습니다. 서버에서만 Gimmick 상태를 변경하고, 클라이언트는 GimmickStateTag Replicate를 통해 자동으로 OnRep_GimmickStateTag()가 호출되는 올바른 흐름으로 수정했습니다.

void AVGMissionBase::OnRep_CurrentStateTag()
{
    if (CurrentStateTag == VigilantMissionTags::MissionCompleted)
    {
        if (HasAuthority()) // 서버에서만 Gimmick 상태 변경
        {
            for (AVGMissionGimmickBase* Gimmick : MissionGimmicks)
            {
                if (Gimmick)
                    Gimmick->SetStateTag(VigilantMissionTags::GimmickCompleted);
            }
        }
        // 클라이언트는 GimmickStateTag Replicate → OnRep_GimmickStateTag 자동 호출
    }
}

5. Gimmick 상태 Tag 분리 유지 결정

Mission.State.*Mission.Gimmick.State.*를 나눌 필요가 있는지 고민했습니다.
결론은 분리 유지입니다. 하나의 미션에서 Gimmick과 Item을 혼합 사용할 때 CheckMissionCondition()에서 각 오브젝트 상태를 개별로 확인해야 하기 때문입니다. MissionCompletedGimmickCompleted가 혼용되면 비교 로직이 복잡해집니다.

대신 잘못된 태그가 들어오는 것을 방어하기 위해 SetStateTag()ensureMsgf와 Completed 이후 변경 차단 로직을 추가했습니다.

void AVGMissionGimmickBase::SetStateTag(FGameplayTag NewStateTag)
{
    if (!HasAuthority()) return;

    // Completed 이후 상태 변경 차단
    if (GimmickStateTag == VigilantMissionTags::GimmickCompleted) return;

    // 유효하지 않은 태그 방어
    ensureMsgf(
        NewStateTag == VigilantMissionTags::GimmickInactive ||
        NewStateTag == VigilantMissionTags::GimmickActive   ||
        NewStateTag == VigilantMissionTags::GimmickCompleted,
        TEXT("[%s] SetStateTag — Invalid tag: %s"),
        *GetName(), *NewStateTag.ToString());

    GimmickStateTag = NewStateTag;
    OnRep_GimmickStateTag();
    OnGimmickStateChanged.Broadcast(this, NewStateTag);
}

6. 현재까지 확인된 정상 동작

  • Mission이 MissionSubsystem에 정상 등록
  • BeginPlay에서 Gimmick에 OwnerMission 주입 및 Delegate 바인딩 성공
  • PressureSequence 순서 조건 체크 정상 동작
  • 상태 변경 시 모든 클라이언트에서 머티리얼 색상 동기화 확인
  • 미션 클리어 시 Gimmick 검은색 전환 확인

다음 작업 예정

  • CurrentSequenceIndex Replicated 처리 검토 (현재 서버 로컬 변수)
  • OnRep_GimmickStateTag 시각 피드백 고도화
  • EquipComponent 연계를 위한 MissionItemBase 확장
profile
게임 개발 지망생입니다.

0개의 댓글