[DAY60] Shooter Project(end) : Project Completion Report

베리투스·2025년 11월 11일
0

Shooter Project

목록 보기
10/10

길었던 슈터 프로젝트의 대장정이 드디어 막을 내렸다. 이 글은 단순한 결과 보고서를 넘어, 프로젝트를 진행하며 마주했던 수많은 버그와의 사투와 그 과정에서 얻은 교훈을 담은 개발 회고록이다. C++ 기반의 AI를 구현하며 겪었던 문제들은 때로는 좌절감을, 때로는 짜릿한 깨달음을 안겨주었다. 이 생생한 경험을 공유하며 프로젝트를 최종 마무리하고자 한다.

https://www.youtube.com/watch?v=FF_hMNYgVZM


1막: 첫 번째 균열 - "AI의 적은 누구인가?"

프로젝트 초기, AI의 기본 시각 센서는 순조롭게 작동했다. 하지만 몬스터를 여러 마리 배치하자마자 AI들이 플레이어는 무시한 채 자기들끼리 싸우는 기괴한 현상이 발생했다.

1.1. 1차 진단: 너무나도 넓었던 '적'의 범위

원인: 문제는 단순했다. OnTargetPerceptionUpdated 함수에서 감지한 대상이 APawn인지만 확인했는데, AI 몬스터 역시 APawn이었기 때문이다. AI들은 서로를 적으로 오인했다.

해결: 이 문제는 감지된 Pawn을 조종하는 컨트롤러가 PlayerController인지 IsA<APlayerController>()로 명확하게 확인하는 로직을 추가함으로써 간단히 해결되었다. AI는 비로소 진짜 적이 누구인지 구분할 수 있게 되었다.

        AActor* DetectedEnemy = nullptr; // 최종적으로 적으로 인식될 액터를 담을 변수

        // 감지된 액터를 '플레이어 캐릭터 클래스'로 캐스팅 시도 (주로 시각 센서)
        APawn* DetectedPawn = Cast<APawn>(Actor);
        if (DetectedPawn && DetectedPawn->GetController() && DetectedPawn->GetController()->IsA<APlayerController>())
        {
            DetectedEnemy = DetectedPawn;
        }

첫 번째 교훈: AI의 조건문은 항상 가장 구체적이고 명확해야 한다. 'Pawn'이라는 모호한 조건이 어떤 부작용을 낳는지 깨달았다.

1.2. 2차 진단: 보이지 않는 암살자, '총알'

AI 내분 문제를 해결하고, 시야 밖의 공격에 반응하도록 피해(Damage) 센서를 추가했다. TakeDamage 함수에 ReportDamageEvent를 추가하여 피해 사실을 Perception System에 보고하도록 설정했다. 하지만 이상하게도, AI는 총에 맞아도 전혀 반응하지 않았다.

원인: 로그를 통해 Damage 자극이 OnTargetPerceptionUpdated 함수에 도달하는 것을 확인했지만, AI는 여전히 타겟을 설정하지 않았다. 범인은 Damage 자극이 전달하는 Actor가 '플레이어'가 아닌 '총알'이었기 때문이다. 우리의 검문 로직(IsA<APlayerController>())은 '총알'을 플레이어가 아니라고 판단하고 그대로 통과시켜 버렸다. AI는 자신을 때린 범인이 눈앞에서 사라지는 총알이라는 것만 알았을 뿐, 그 총알을 쏜 저격수가 누구인지는 알지 못했다.

해결: 우리는 검문 로직을 한 단계 더 확장했다.
1. 먼저, 자극의 출처(Actor)가 플레이어인지 확인한다 (시각 센서를 위해).
2. 만약 아니라면, 그 Actor주인(GetInstigator())을 찾는다.
3. 그리고 그 주인이 PlayerController에 의해 조종되는지 확인한다.

이 로직을 통해, AI는 이제 총알(Actor) 뒤에 숨어있는 플레이어(Instigator)를 정확히 찾아내어 적으로 인식하고 즉시 반격할 수 있게 되었다.

        // 자극 출처의 주인(Instigator)이 플레이어가 조종하는 Pawn인지 확인 (주로 데미지 센서)
        else if (Actor->GetInstigator())
        {
            APawn* InstigatorPawn = Cast<APawn>(Actor->GetInstigator());
            if (InstigatorPawn && InstigatorPawn->GetController() && InstigatorPawn->GetController()->IsA<APlayerController>())
            {
                DetectedEnemy = InstigatorPawn;
            }
        }

        // 위 두 가지 경우 중 하나라도 해당되어 유효한 적(DetectedEnemy)을 찾았다면, 블랙보드의 'TargetActor', 'LastKnownLocation' 키를 업데이트
        if (DetectedEnemy)
        {
            MyBlackboard->SetValueAsObject(TEXT("TargetActor"), DetectedEnemy);
            MyBlackboard->SetValueAsVector(TEXT("LastKnownLocation"), DetectedEnemy->GetActorLocation());
        }

두 번째 교훈: AI Perception이 전달하는 Actor는 이벤트의 '직접적인 원인'일 뿐, '궁극적인 배후'가 아닐 수 있다. 자극의 종류에 따라 Instigator를 추적하는 유연한 사고가 필요하다.


2막: 고질병과의 사투 - "공격 모션이 회전을 삼켜버렸다"

AI가 플레이어를 잘 따라다니며 공격하게 되었지만, 새로운 문제가 발생했다. 공격 애니메이션이 재생되는 동안 AI가 그 자리에 고정되어 플레이어가 조금만 움직여도 허공에 주먹질을 하는 것이었다. 공격과 동시에 목표물을 향해 부드럽게 회전하는 기능이 절실했다.

시도와 실패: 처음에는 행동 트리의 SimpleParallel 노드를 사용해 '공격'과 '회전'을 동시에 실행하려 했다. 하지만 이 방법은 애니메이션이 끝날 때까지 기다리는 비동기(Asynchronous) 처리가 불안정하여 공격이 중간에 끊기는 버그를 유발했다.

최종 설계: 역할의 완벽한 분리

우리는 두 동작의 역할을 명확히 분리하는 것으로 이 문제를 해결했다.
1. 행동 트리 Task (BTT_PlayMontage): '지휘관' 역할을 맡아 애니메이션 재생 시간을 보장하고, 시작과 끝에 컨트롤러에게 "회전 시작/종료" 신호만 보낸다.
2. AI 컨트롤러 (AIC_Monster): '실행자'가 되어, Tick 함수에서 회전 신호(bIsRotatingToTarget)가 켜져 있을 때만 실제 회전 계산을 수행하고, 캐릭터의 회전 모드를 동적으로 전환하여 애니메이션과의 충돌을 직접 제어한다.

이 구조 덕분에 성능, 제어의 정확성, 그리고 확장성(하나의 Task로 모든 스킬 처리)을 모두 확보할 수 있었다. 이 아키텍처는 이번 프로젝트에서 가장 심혈을 기울여 설계한 핵심 파트이다.

void AAIC_Monster::StartRotatingToTarget()
{
    bIsRotatingToTarget = true;

    if (ACharacter* ControlledPawn = Cast<ACharacter>(GetPawn()))
    {
        // 캐릭터의 회전 제어권을 '이동방향'에서 '컨트롤러'로 전환
        ControlledPawn->GetCharacterMovement()->bOrientRotationToMovement = false;
        ControlledPawn->GetCharacterMovement()->bUseControllerDesiredRotation = true;
        ControlledPawn->bUseControllerRotationYaw = true;
    }
}
void AAIC_Monster::Tick(float DeltaSeconds)
{
    Super::Tick(DeltaSeconds);

    if (bIsRotatingToTarget)
    {   
        ACharacter* ControlledPawn = Cast<ACharacter>(GetPawn());
        AActor* TargetActor = Cast<AActor>(GetBlackboardComponent()->GetValueAsObject(TEXT("TargetActor")));

        if (ControlledPawn && TargetActor)
        {
            // 목표물을 향하는 방향 벡터 계산, 수직 축은 무시, 목표 회전값(Rotation) 계산
            FVector LookVector = TargetActor->GetActorLocation() - ControlledPawn->GetActorLocation();
            LookVector.Z = 0.f; 
            FRotator TargetRotation = LookVector.Rotation();

            // 부드러운 회전을 위해 보간(Interpolation) 사용
            // RInterpTo(현재 회전값, 목표 회전값, 경과 시간, 회전 속도)
            FRotator NewRotation = FMath::RInterpTo(ControlledPawn->GetActorRotation(), TargetRotation, DeltaSeconds, 5.0f);

            // Pawn의 회전값을 직접 설정하는 대신, 컨트롤러의 회전값을 설정
            // Pawn의 bUseControllerRotationYaw 설정에 따라 Pawn이 부드럽게 따라 회전함
            SetControlRotation(NewRotation);
        }
    }
}

세 번째 교훈: 복잡한 동시성 문제는 각 클래스에 가장 적합한 역할을 부여하고, 명확한 신호를 통해 서로 독립적으로 작동하도록 설계할 때 가장 우아하게 해결된다.


3막: 미스터리 - "어째서 내 AI는 장님이 되었나?"

프로젝트가 중반을 넘어갈 무렵, 가장 절망적인 위기가 찾아왔다. 특별한 코드 수정도 없었는데, 어느 날 갑자기 모든 AI의 시각, 피해, 청각 센서가 '전부' 먹통이 된 것이다. 근접을 감지하는 AggroSphere만 유일하게 작동할 뿐, AI 디버거에는 아무런 시야각도 표시되지 않았다.

탐정의 추리 과정: 모든 알리바이는 완벽했다

문제 해결을 위해 모든 용의자를 심문하기 시작했다.

  • 용의자 1: C++ 코드? -> AIC_Monster.cpp의 센서 설정 코드는 명백히 정상이었다.
  • 용의자 2: 블루프린트 설정? -> AIC_Monster를 상속받는 블루프린트가 C++ 설정을 덮어쓰는지 확인했지만, Senses Config 배열은 완벽했다.
  • 용의자 3: 충돌 및 소스 문제? -> 플레이어와 몬스터의 콜리전 설정, Stimuli Source 컴포넌트 모두 이상 없었다.

모든 논리적인 추리가 막다른 길에 다다랐을 때, 마지막 단서를 발견했다. 게임 플레이 중 월드 아웃라이너를 확인했을 때, 당연히 있어야 할 AIPerceptionSystem이 존재하지 않았던 것이다. AI의 모든 감각을 총괄하는 중앙 관리 시스템이 원인 불명으로 실종된 상태였다.

범인과 해결: 논리의 끝에서 찾은 '껐다 켜기'

AIPerceptionSystem이 생성되지 않는 가장 유력한 원인은 프로젝트의 빌드 설정 파일인 .Build.cs"AIModule" 의존성이 누락된 경우다. 하지만 놀랍게도, .Build.cs 파일에는 이미 "AIModule"이 정상적으로 포함되어 있었다.

코드는 옳았고, 설정도 옳았다. 논리적으로는 작동해야만 했다. 범인은 코드가 아니라, 코드와 실제 실행 파일 사이의 '어딘가'에 숨어있는 유령이었다.

결국 나는 최후의 수단을 사용했다. 바로 '프로젝트 클린 앤 리빌드(Clean and Rebuild)'였다. 결과는 허무했다. 아무런 코드 수정 없이, 게임을 다시 실행하자 월드 아웃라이너에 AIPerceptionSystem이 정상적으로 생성되었고, 모든 AI는 기적처럼 모든 감각을 되찾았다.

정확한 원인은 결국 미궁 속에 남았다. 아마도 언리얼 빌드 툴(UBT)이 어떤 이유로 모듈 의존성을 제대로 인지하지 못하는 캐시 문제를 일으켰던 것으로 추정할 뿐이다.

PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput", "Networking", "UMG", "AIModule", "NavigationSystem", "Slate", "SlateCore", "ControlRig" });

네 번째 교훈: 코드가 완벽하다고 확신할 때, 진짜 문제는 눈에 보이지 않는 빌드 시스템 자체에 있을 수 있다. 원인을 알 수 없는 버그와 마주했을 때, 가장 먼저 시도해봐야 할 것은 가장 원시적인 해결책, 즉 '모든 것을 지우고 처음부터 다시 빌드하는 것'일지도 모른다.


마치며

이 외에도 래그돌이 치즈처럼 늘어나는 문제, 위젯이 충돌을 방해하는 문제 등 수많은 버그들이 우리를 괴롭혔지만, 체계적인 디버깅과 원인 분석을 통해 모두 해결해낼 수 있었다. 이번 프로젝트는 단순히 슈터 게임 AI를 만드는 것을 넘어, 복잡한 시스템의 문제를 진단하고, 더 나은 구조를 고민하며 한 단계 더 성장할 수 있었던 귀중한 경험이었다.

profile
Shin Ji Yong // Unreal Engine 5 공부중입니다~

0개의 댓글