상세 컨텐츠

본문 제목

Unreal C++ 싱글플레이 FPS 슈터 게임 팀 프로젝트 구조 정리 및 트러블슈팅

Unreal C++

by hyunjunstar 2026. 5. 26. 23:55

본문

적 AI 구현 

EnemySpawner

 - NavMesh 위에 적 랜덤 생성

 EnemyAIController
 - 플레이어를 0.2초마다 탐지
 - 감지되면 Blackboard.TargetActor에 저장

Behavior Tree
 - TargetActor가 있으면 추적
 - 공격 범위 안이면 공격 Task 실행

 

BTDecorator_IsInAttackRange
 - 플레이어가 공격 범위 안인지 검사

BTTask_AttackTarget
 - 이동 정지
 - 플레이어 방향으로 회전
 - 공격 애니메이션 실행

EnemyCharacter
 - HP, 공격력, 탐지/공격 범위 관리
 - Anim Notify 시점에 실제 데미지 적용

 - 사망 시 점수 지급, 이동/충돌 비활성화

1. 적 캐릭터 구현

1-1. 체력 / 방어력 시스템 구현

    - EnemyCharacter에서 MaxHP, CurrentHP, Defense 관리
    - 피격 시 방어력을 반영해 최종 데미지 계산

1-2. 피격 / 사망 처리

    - TakeDamage()로 데미지 수신
    - HP가 0 이하가 되면 Die() 실행
    - 사망 시 점수 지급, 이동 비활성화, 충돌 제거, 사망 애니메이션 재생

2. 플레이어 탐지

 - EnemyAIController에서 플레이어를 0.2초마다 탐지
 - 적과 플레이어 사이의 거리를 계산
 - DetectionRange 안에 있으면 Blackboard의 TargetActor에 플레이어 저장
 - 범위 밖이면 TargetActor 제거

3. 플레이어 추적

 - Behavior Tree에서 TargetActor가 존재하면 추적 로직 실행
 - Blackboard에 저장된 플레이어를 기준으로 이동
 - NavMesh를 사용해 이동 가능한 경로를 따라 추적

4. 공격 범위 내 공격

 - BTDecorator_IsInAttackRange에서 플레이어가 공격 범위 안에 있는지 검사
 - 범위 안이면 BTTask_AttackTarget 실행
 - 공격 Task에서 이동 정지
 - 플레이어 방향으로 회전
 - 공격 애니메이션 실행
 - 실제 데미지는 애니메이션 Notify 시점에 ApplyAttackDamage()로 적용

5. NavMesh 기반 경로 탐색

 - EnemySpawner가 NavMesh 위의 랜덤 위치를 찾아 적 생성
 - AI 이동도 NavMesh 기반으로 처리
 - 벽이나 이동 불가능한 영역을 피해 플레이어에게 접근

6. 장애물 회피

 - EnemyCharacter의 CharacterMovement에 RVO Avoidance 적용
 - 여러 적이 동시에 이동할 때 서로 겹치는 현상 완화
 - 회피 반경을 설정해 적들이 자연스럽게 퍼져 이동하도록 처리

추가 리스트

1. 적 AI EnemySpawner

 - 스폰할 적 클래스 EnemyClass 지정
 - 스폰 개수 EnemySpawnCount 설정
 - 스폰 반경 SpawnRadius 설정
 - 충돌 시 스폰 실패 처리
 - 무한 반복 방지를 위한 최대 스폰 시도 횟수 적용

2. 스킬 공격 / Grenade 추가 리스트

스킬공격 구현

CombatComponent.FireGrenade()
 - 스킬 사용 가능 여부 확인
 - 손 소켓(hand_rSocket) 위치 계산
 - 카메라 기준 조준 방향 보정
 - GrenadeProjectile 생성
 - 데미지 / 폭발 반경 전달

GrenadeProjectile 코드 구조

CollisionComp
 - 충돌 판정용 SphereComponent

MeshComp
 - 외형 표시용 StaticMeshComponent

ProjectileMovement
 - 속도, 중력, 바운스, 마찰 적용

FuseTimer
 - 일정 시간 후 Explode 실행

 

Explode()
 - 현재 위치에 폭발 이펙트 생성
 - 폭발 사운드 재생
 - ApplyRadialDamage로 범위 데미지 적용
 - 투사체 Destroy

트러블슈팅

적 AI

문제 1. 공격 애니메이션과 데미지 타이밍이 안 맞음

// ========== 기존 코드 ========== // 

// BTTask_AttackTarget.cpp

UGameplayStatics::ApplyDamage(
    TargetActor,
    Enemy->AttackDamage,
    AIController,
    Enemy,
    nullptr
    );
// ========== 수정 후 코드 ========== // 

// BTTask_AttackTarget.cpp

Enemy->PlayAttackAnimation();
Enemy->MarkAttack();

// EnemyCharacter.h

UFUNCTION(BlueprintCallable, Category = "Enemy|Attack")
void ApplyAttackDamage();

// EnemyCharacter.cpp

void AEnemyCharacter::ApplyAttackDamage()
{
    if (bIsDead)
    {
        return;
    }
    AAIController* AIController = Cast<AAIController>(GetController());
    if (!AIController)
    {
        return;
    }
    UBlackboardComponent* BlackboardComp = AIController->GetBlackboardComponent();
    if (!BlackboardComp)
    {
        return;
    }

    AActor* TargetActor = Cast<AActor>(
        BlackboardComp->GetValueAsObject(TEXT("TargetActor"))
    );

    if (!TargetActor)
    {
        return;
    }

    UGameplayStatics::ApplyDamage(
        TargetActor,
        AttackDamage,
        AIController,
        this,
        nullptr
        );
}

 - BTTask에서 바로 데미지를 주지 않고, Anim Notify에서 ApplyAttackDamage 호출로 해결

문제 2. 적이 죽은 뒤에도 움직이거나 공격하려고 함

// ========== 기존 코드 ========== // 

// EnemyCharacter.cpp

void AEnemyCharacter::Die()
{
    if (bIsDead)
    {
        return;
    }
    bIsDead = true;
    AShooterGameMode* GM = Cast<AShooterGameMode>(UGameplayStatics::GetGameMode(this));
     
    if (GM)
    {
        GM->AddScore(ScoreValue);
    }
    Destroy();
}
// ========== 수정후 코드 ========== // 

// BTTask_AttackTarget.cpp

AEnemyCharacter* Enemy = Cast<AEnemyCharacter>(AIController->GetPawn());
if (!Enemy || Enemy->IsDead())
{
    return EBTNodeResult::Failed;
}

// BTDecorator_IsInAttackRange.cpp

const AEnemyCharacter* Enemy = Cast<AEnemyCharacter>(AIController->GetPawn());
if (!Enemy || Enemy->IsDead())
{
    return false;
}

// EnemyCharacter.cpp

void AEnemyCharacter::Die()
{
    if (bIsDead)
    {
        return;
    }
    
    bIsDead = true;

    AShooterGameMode* GM = Cast<AShooterGameMode>(UGameplayStatics::GetGameMode(this));
    if (GM)
    {
        GM->AddScore(ScoreValue);
    }

    GetCharacterMovement()->DisableMovement();
    GetCharacterMovement()->StopMovementImmediately();
    GetCapsuleComponent()->SetCollisionEnabled(ECollisionEnabled::NoCollision);

    PlayDeathAnimation();

    SetLifeSpan(2.5f);
}

 - IsDead 체크 추가, 사망 시 Movement/Collision 비활성화로 해결

문제 3. 플레이어가 공격 범위 밖으로 벗어나도 맞는 문제

// ========== 기존 코드 ========== // 

// BTDecorator_IsInAttackRange.cpp

const float Distance = FVector::Dist2D(
    Enemy->GetActorLocation(),
    TargetActor->GetActorLocation()
    );

return Distance <= Enemy->AttackRange;
// ========== 수정후 코드 ========== // 
  
// EnemyCharacter.cpp
void AEnemyCharacter::ApplyAttackDamage()
{
    if (bIsDead)
    {
        return;
    }

    AAIController* AIController = Cast<AAIController>(GetController());
    if (!AIController)
    {
        return;
    }

    UBlackboardComponent* BlackboardComp = AIController->GetBlackboardComponent();
    if (!BlackboardComp)
    {
        return;
    }

    AActor* TargetActor = Cast<AActor>(
        BlackboardComp->GetValueAsObject(TEXT("TargetActor"))
        );

    if (!TargetActor)
    {
        return;
    }

    const float DistanceToTarget = FVector::Dist2D(
        GetActorLocation(),
        TargetActor->GetActorLocation()
        );

    if (DistanceToTarget > AttackRange + 50.f)
    {
        return;
    }

    UGameplayStatics::ApplyDamage(
        TargetActor,
        AttackDamage,
        AIController,
        this,
        nullptr
    );
}

 - Notify 시점에 AttackRange + 보정값으로 거리 재검사로 해결

문제 4. 여러 적이 겹쳐서 이동이 어색함

// ========== 기존 코드 ========== // 

// EnemyCharacter.cpp

AEnemyCharacter::AEnemyCharacter()
{
    PrimaryActorTick.bCanEverTick = false;

    MaxHP = 100.f;
    CurrentHP = MaxHP;
    Defense = 0.f;
    ScoreValue = 1;
    AttackDamage = 10.f;
    DetectionRange = 1200.f;
    AttackRange = 150.f;
    AttackCooldown = 1.5f;
    bIsDead = false;
}
// ========== 수정후 코드 ========== // 

// EnemyCharacter.cpp

AEnemyCharacter::AEnemyCharacter()
{
    PrimaryActorTick.bCanEverTick = false;

    MaxHP = 100.f;
    CurrentHP = MaxHP;
    Defense = 0.f;
    ScoreValue = 1;
    AttackDamage = 10.f;
    DetectionRange = 1200.f;
    AttackRange = 150.f;
    AttackCooldown = 1.5f;
    LastAttackTime = -AttackCooldown;
    bIsDead = false;

    GetCharacterMovement()->bUseRVOAvoidance = true;
    GetCharacterMovement()->AvoidanceConsiderationRadius = 300.f;

    bUseControllerRotationYaw = false;
    GetCharacterMovement()->bOrientRotationToMovement = true;
    GetCharacterMovement()->RotationRate = FRotator(0.f, 720.f, 0.f);
}

 - CharacterMovement에 RVO Avoidance 적용으로 해결

스킬 공격

문제 1. 투사체로 변경 후 발사하자마자 플레이어와 충돌하는 문제

// ========== 기존 코드 ========== // 

// CombatComponent.cpp

FVector ExplodeLocation = BaseStart + (ShootDirection * 400.f);

TArray<AActor*> IgnoreActors;
IgnoreActors.Add(Owner);

UGameplayStatics::ApplyRadialDamage(
    GetWorld(),
    GrenadeDamage,
    ExplodeLocation,
    GrenadeRadius,
    UDamageType::StaticClass(),
    IgnoreActors,
    Owner,
    Owner->GetInstigatorController(),
    true
    );
// ========== 수정후 코드 ========== // 

// CombatComponent.cpp

FActorSpawnParameters SpawnParams;
SpawnParams.Owner = Owner;
SpawnParams.Instigator = Cast<APawn>(Owner);
SpawnParams.SpawnCollisionHandlingOverride =
    ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn;

AGrenadeProjectile* Grenade = GetWorld()->SpawnActor<AGrenadeProjectile>(
    GrenadeProjectileClass,
    SpawnLocation,
    ShootDirection.Rotation(),
    SpawnParams
    );
    
// GrenadeProjectile.cpp

void AGrenadeProjectile::BeginPlay()
{
    Super::BeginPlay();

    if (AActor* OwnerActor = GetOwner())
    {
        CollisionComp->IgnoreActorWhenMoving(OwnerActor, true);
    }

    GetWorldTimerManager().SetTimer(
        FuseTimerHandle,
        this,
        &AGrenadeProjectile::Explode,
        FuseTime,
        false
        );
  }

 - GrenadeProjectile BeginPlay에서 OwnerActor를 충돌 무시 처리로 해결

 

문제 2. 조준 방향과 실제 발사 방향이 어긋남

// ========== 기존 코드 ========== // 

// CombatComponent.cpp

FVector ShootDirection = Owner->GetActorForwardVector();
FVector BaseStart = Owner->GetActorLocation();

if (CharOwner)
{
    if (APlayerController* PC = Cast<APlayerController>(CharOwner->GetController()))
    {
        FVector CameraLoc;
        FRotator CameraRot;
        PC->GetPlayerViewPoint(CameraLoc, CameraRot);

        ShootDirection = CameraRot.Vector();
        BaseStart = CameraLoc;
    }
}

FVector ExplodeLocation = BaseStart + (ShootDirection * 400.f);
// ========== 수정후 코드 ========== // 

// CombatComponent.cpp

ACharacter* CharOwner = Cast<ACharacter>(Owner);

FVector SpawnLocation = Owner->GetActorLocation();
FVector ShootDirection = Owner->GetActorForwardVector();

if (CharOwner)
{
    if (CharOwner->GetMesh()->DoesSocketExist(TEXT("hand_rSocket")))
    {
        SpawnLocation = CharOwner->GetMesh()->GetSocketLocation(TEXT("hand_rSocket"));
    }

    if (APlayerController* PC = Cast<APlayerController>(CharOwner->GetController()))
    {
        FVector CameraLoc;
        FRotator CameraRot;
        PC->GetPlayerViewPoint(CameraLoc, CameraRot);

        FVector TraceStart = CameraLoc;
        FVector TraceEnd = TraceStart + CameraRot.Vector() * 3000.f;

        FHitResult HitResult;
        FCollisionQueryParams Params;
        Params.AddIgnoredActor(Owner);

        bool bHit = GetWorld()->LineTraceSingleByChannel(
            HitResult,
            TraceStart,
            TraceEnd,
            ECC_Visibility,
            Params
            );

        FVector TargetPoint = bHit ? HitResult.ImpactPoint : TraceEnd;

        ShootDirection = (TargetPoint - SpawnLocation).GetSafeNormal();
    }
}

SpawnLocation += ShootDirection * 40.f;
SpawnLocation += FVector(0.f, 0.f, 10.f);

AGrenadeProjectile* Grenade = GetWorld()->SpawnActor<AGrenadeProjectile>(
    GrenadeProjectileClass,
    SpawnLocation,
    ShootDirection.Rotation(),
    SpawnParams
    );

 - 카메라 LineTrace 기준으로 목표 지점을 계산하고 손 소켓에서 그 방향으로 발사로 해결

 

문제 3. 폭발 연출과 데미지 처리가 분리되어 어색함

// ========== 기존 코드 ========== // 

// CombatComponent.cpp

FVector ExplodeLocation = BaseStart + (ShootDirection * 400.f);

DrawDebugSphere(
    GetWorld(),
    ExplodeLocation,
    GrenadeRadius,
    16,
    FColor::Purple,
    false,
    2.0f,
    0,
    1.5f
    );

TArray<AActor*> IgnoreActors;
IgnoreActors.Add(Owner);

UGameplayStatics::ApplyRadialDamage(
    GetWorld(),
    GrenadeDamage,
    ExplodeLocation,
    GrenadeRadius,
    UDamageType::StaticClass(),
    IgnoreActors,
    Owner,
    Owner->GetInstigatorController(),
    true
    );
// ========== 수정후 코드 ========== // 

// CombatComponent.cpp

AGrenadeProjectile* Grenade = GetWorld()->SpawnActor<AGrenadeProjectile>(
    GrenadeProjectileClass,
    SpawnLocation,
    ShootDirection.Rotation(),
    SpawnParams
    );

if (Grenade)
{
    Grenade->SetExplosionData(GrenadeDamage, GrenadeRadius);
}

// GrenadeProjectile.cpp

void AGrenadeProjectile::SetExplosionData(float InDamage, float InRadius)
{
    ExplosionDamage = InDamage;
    ExplosionRadius = InRadius;
}

void AGrenadeProjectile::Explode()
{
    if (bHasExploded) return;
    bHasExploded = true;

    FVector ExplodeLocation = GetActorLocation();

    if (GrenadeEffect)
    {
        UGameplayStatics::SpawnEmitterAtLocation(
            GetWorld(),
            GrenadeEffect,
            ExplodeLocation
        );
    }

    if (GrenadeSound)
    {
        UGameplayStatics::PlaySoundAtLocation(
            GetWorld(),
            GrenadeSound,
            ExplodeLocation,
            GrenadeSoundVolume
            );
    }

    TArray<AActor*> IgnoreActors;
    if (GetOwner())
    {
        IgnoreActors.Add(GetOwner());
    }

    UGameplayStatics::ApplyRadialDamage(
        GetWorld(),
        ExplosionDamage,
        ExplodeLocation,
        ExplosionRadius,
        UDamageType::StaticClass(),
        IgnoreActors,
        this,
        GetInstigatorController(),
        true
        );

    Destroy();
}

 - Explode 함수 안에서 이펙트, 사운드, 범위 데미지를 한 번에 처리로 해결

관련글 더보기