← 모든 글

언리얼 팀프로젝트 개발기 2 - 역할 분담과 핵심 시스템 구현

Project Priest의 게임모드, UI, 캐릭터 이동, 전투, 적 AI 기능을 팀원별로 나누고 첫 번째 플레이 가능한 전투 흐름을 만들기 시작한 과정을 정리했습니다.

Unreal Engine 5 C++Unreal Engine 5Team ProjectProject PriestGameModeUICharacterCombatEnemy AIBehavior Tree

기획을 실제 작업 단위로 나누기

지난 글: 새로운 언리얼 팀프로젝트의 시작

지난 글에서는 성직자가 총과 스킬로 몬스터를 상대하는 타임어택 액션 게임의 큰 방향을 정했다. 이번에는 아이디어를 실제로 구현할 수 있도록 기능을 나누고, 세 명의 팀원이 동시에 작업할 수 있는 단위로 담당 영역을 정했다.

프로젝트 이름은 Project Priest다. 플레이어는 마을을 탐색하며 몬스터와 전투하고, 마지막에는 보스가 있는 장소에 도달하는 것을 목표로 한다. 점수를 많이 얻는 방식보다 클리어까지 걸린 시간을 기록하는 방식이 게임의 목표와 더 잘 맞는다고 판단해, 초기 기획에 있던 적 처치 점수는 경과 시간 기록으로 대체했다.

현재 단계의 목표는 모든 기능을 완성하는 것이 아니다. 먼저 게임 시작부터 이동, 전투, 적의 반응, 플레이어 사망 또는 보스 룸 도달까지 이어지는 최소 플레이 흐름을 만드는 데 집중한다.

역할 분담

기능은 게임모드와 캐릭터, UI, 전투, 적 AI의 다섯 영역으로 나눴다. 서로 완전히 독립적인 기능은 아니지만 각 영역의 중심 책임자를 정해 작업 방향이 겹치지 않도록 했다.

담당작업 영역핵심 목표
주정열게임모드시작 시각 기록, 사망·클리어 조건과 게임 종료 흐름 관리
팀원 AUI체력, 탄약, 조준점, 전투 피드백과 미션 진행 표시
주정열캐릭터 이동이동·시점·달리기·점프와 상태별 애니메이션 연결
팀원 B전투 로직히트 스캔 총기, 성수 투척, 쿨타임과 범위 피해 구현
팀원 C적 AI탐지·순찰·추적·공격·피격·사망 흐름 구현

역할을 나눌 때는 클래스의 소유권만 정하지 않았다. 각 기능이 다른 영역에 어떤 값을 전달해야 하는지도 함께 확인했다. 예를 들어 전투 로직이 피해를 발생시키면 적 캐릭터가 체력을 변경하고, 사망 결과는 게임 진행과 UI에 전달되어야 한다. AI가 플레이어를 발견하면 Blackboard의 값이 바뀌고, Behavior Tree가 순찰에서 추적과 공격으로 전환되어야 한다.

반복되는 디버그 출력을 매크로로 줄이기

팀 프로젝트를 진행하면서 같은 검증 코드를 여러 클래스에 반복해서 작성하는 일이 많아졌다. Unreal의 UE_LOG()는 Output Log를 확인할 때 유용하지만, 플레이 도중 에디터 화면에서도 메시지를 바로 보려면 GEngine->AddOnScreenDebugMessage()를 따로 호출해야 한다.

UE_LOG(LogTemp, Log, TEXT("Game state is invalid"));

if (GEngine)
{
    GEngine->AddOnScreenDebugMessage(
        -1,
        10.0f,
        FColor::Red,
        TEXT("Game state is invalid"));
}

검사할 대상이 늘어날 때마다 로그와 화면 메시지를 함께 작성하는 것은 번거롭고, 한쪽만 작성하면 디버깅 정보를 놓치기 쉽다. 그래서 두 출력을 한 번에 처리하는 JLog, JWarning, JError 매크로를 JUtility.h에 만들었다.

#define JError(Format, ...) \
UE_LOG(LogTemp, Log, TEXT(Format), ##__VA_ARGS__); \
if (GEngine) { \
    GEngine->AddOnScreenDebugMessage( \
        -1, \
        10.0f, \
        FColor::Red, \
        FString::Printf(TEXT(Format), ##__VA_ARGS__)); \
}

JLog는 흰색, JWarning은 노란색, JError는 빨간색 화면 메시지를 출력한다. 세 매크로 모두 같은 Format 문자열과 가변 인자를 Output Log와 화면 메시지에 전달하므로, 값이 포함된 로그도 한 줄로 작성할 수 있다.

JLog("%s Entered", *DisplayName);
JError("클리어 화면을 구현하세요 클리어 시간은 %f", ElapsedTime);

유효성 검사와 조기 반환도 자주 반복되어 JASSERT 계열 매크로로 묶었다. 조건이 거짓이면 JError를 호출해 두 위치에 오류를 출력하고 현재 함수에서 즉시 빠져나온다.

#define JASSERT(Condition, Format, ...) \
if (!(Condition)) { \
    JError(Format, ##__VA_ARGS__); \
    return; \
}

반환형에 따라 JASSERT, JASSERT_INT, JASSERT_BOOL, JASSERT_NULLPTR를 나눴다. 각각 void, int, bool, 포인터를 반환하는 함수에서 사용할 수 있다.

void AIngameGameMode::BeginPlay()
{
    JASSERT(IsValid(GameState),
        "Game mode is not valid");

    IngameState = Cast<AIngameGameState>(GameState);

    JASSERT(IsValid(IngameState),
        "Game state is not AIngameGameState");
}

이 매크로는 Unreal의 check처럼 프로그램을 즉시 중단시키기 위한 Assert라기보다, 개발 중 오류 상황을 눈에 띄게 알리고 잘못된 상태에서 이후 코드가 실행되는 것을 막는 Guard Clause에 가깝다. 긴 검증 코드를 짧게 통일하면서 팀원이 같은 형식으로 로그를 남길 수 있다는 점도 장점이다.

기존 Unreal의 checkf를 사용하면 조건이 실패했을 때 즉시 브레이크가 걸리거나 이후 코드 진행이 중단된다. 반드시 고쳐야 하는 치명적인 오류를 찾을 때는 유용하지만, 여러 기능을 연결하며 반복해서 플레이하는 개발 단계에서는 작은 설정 누락 때문에 테스트 전체가 멈추는 일이 불편했다. 그래서 JASSERT는 프로그램을 강제로 중단시키는 대신 오류 메시지를 Output Log와 화면에 모두 출력하고 현재 함수에서만 return하도록 만들었다. 오류가 발생한 위치와 원인은 바로 확인하면서도 나머지 테스트를 이어갈 수 있어 개발 편의성이 높아졌다.

다만 매크로 안에서 return까지 수행하므로 함수의 반환형에 맞는 매크로를 사용해야 한다.

게임모드: 시작과 끝의 기준 만들기

게임은 별도의 시작 버튼이나 카운트다운 없이 레벨이 열리면 바로 시작한다. AIngameGameMode::BeginPlay()에서 전용 GameState를 가져오고 현재 월드 시간을 시작 시각으로 저장한다.

void AIngameGameMode::BeginPlay()
{
    JASSERT(IsValid(GameState), "Game mode is not valid");

    IngameState = Cast<AIngameGameState>(GameState);
    JASSERT(IsValid(IngameState),
        "Game state is not AIngameGameState");

    IngameState->SetStartTime(GetWorld()->TimeSeconds);
}

적을 처치할 때마다 점수를 더하는 대신 게임을 끝내기까지 걸린 시간을 결과로 사용한다. 보스 룸을 여는 시점에는 현재 시간에서 시작 시간을 빼 경과 시간을 계산한다.

float StartTime = IngameState->GetStartTime();
float NowTime = GetWorld()->GetTimeSeconds();
float ElapsedTime = NowTime - StartTime;

현재 게임 종료 경로는 두 가지로 계획했다.

  1. 플레이어의 체력이 0이 되면 게임 오버 화면으로 이동한다.
  2. 보스 룸을 열거나 이후 보스를 처치하면 클리어 처리하고 경과 시간을 보여준다.

보스는 후속 구현 사항이므로 현재는 보스 룸 진입을 임시 클리어 조건으로 사용한다. OnPlayerDead()OnOpenBossRoomDoor()의 진입점은 준비했으며, 실제 게임 오버·클리어 Widget과 레벨 전환은 UI 작업과 함께 연결할 예정이다. 제한 시간이나 별도의 미션 실패 조건 역시 기본 전투 흐름이 완성된 뒤 추가한다.

UI: 전투에 필요한 정보를 한 화면에 모으기

UI는 플레이어가 지금 어떤 상태인지 즉시 판단할 수 있도록 구성한다. 좌측 하단에는 체력바를 배치하고, 무기와 남은 탄약 정보도 같은 전투 HUD에서 확인할 수 있게 할 계획이다.

화면 중앙에는 조준점과 크로스헤어를 표시한다. 총알이 적에게 맞았을 때는 히트마커를 보여주고, 실제 피해량과 적 처치 여부도 별도의 피드백으로 전달한다. 단순히 총소리만 재생하는 것보다 명중, 피해, 처치를 서로 다른 시각 정보로 보여주면 전투 결과를 더 빠르게 이해할 수 있다.

추가로 다음 정보를 HUD에 표시할 예정이다.

초기 기획의 점수와 킬 카운트는 타임어택 방향에 맞춰 우선순위를 조정한다. 킬 카운트는 전투 진행 정보로 사용할 수 있지만, 최종 결과의 중심 값은 점수가 아니라 경과 시간이 된다.

캐릭터 이동과 상태 변화

플레이어 캐릭터는 키보드와 마우스로 이동 및 시점을 조작한다. W, A, S, D로 걷고 Shift를 함께 누르면 달리며 Space로 점프한다. 이동 입력만 처리하는 것으로 끝내지 않고 현재 상태에 따라 이동 속도와 애니메이션이 함께 바뀌도록 연결할 계획이다.

캐릭터 이동 기능은 다음 순서로 구현한다.

  1. Enhanced Input으로 이동과 시점 입력을 받는다.
  2. 걷기와 달리기에 서로 다른 최대 이동 속도를 적용한다.
  3. 점프 시작, 공중, 착지 상태를 Animation Blueprint에 전달한다.
  4. 이동 상태에 맞는 애니메이션을 재생한다.
  5. 필요한 동작에서는 Capsule Collision 크기도 조정한다.

프로젝트에는 IA_Move, IA_Look, IA_Jump, IA_Sprint, IA_Fire, IA_Throw Input Action이 준비되어 있다. 이동과 전투 입력을 같은 Mapping Context에서 관리하되, 실제 동작은 Player Character와 전투 시스템이 나누어 처리한다.

전투 로직: 총과 성수 투척

기본 공격은 총을 사용하는 히트 스캔 방식으로 구현한다. 카메라 또는 총구에서 선을 발사해 가장 먼저 맞은 대상을 찾고, 유효한 적이라면 피해를 전달한다. 투사체가 실제 공간을 날아가는 방식보다 판정이 즉시 발생하므로 기본 총기 구현에 적합하다.

특수 공격은 F 키로 성수를 던지는 기술이다. 프로젝트에는 AHolyGenerade 클래스와 성수 모델, 폭발 및 마법 효과에 사용할 VFX 리소스가 추가되어 있다. 성수는 투사체처럼 날아간 뒤 충돌하거나 일정 시간이 지나면 폭발하고, 주변 범위의 적에게 피해를 주는 방향으로 연결한다.

전투 시스템에서 필요한 판정은 공격 종류에 따라 나뉜다.

특수 공격에는 쿨타임을 적용해 연속 사용을 제한한다. UI에서는 남은 쿨타임을 표시해 플레이어가 다음 사용 가능 시점을 알 수 있도록 해야 한다.

적 AI: 순찰에서 공격까지

현재 프로젝트에서 가장 구체적으로 진행된 부분은 적 AI다. 기본 적 클래스인 AEnemyCharacter는 체력, 공격력, 방어력, 최소 피해량과 순찰 기준 위치 및 반경을 가진다. 월드에 배치되거나 Spawn될 때 AMonsterAIController가 자동으로 Possess하도록 설정했다.

AI Controller에는 UAIPerceptionComponent와 Sight 설정이 들어 있다. 플레이어를 성공적으로 감지하면 감지된 Actor와 위치를 저장하고, 시야에서 놓치면 값을 비운다.

void AMonsterAIController::OnPerceptionUpdated(
    AActor* Actor,
    FAIStimulus Stimulus)
{
    if (Stimulus.WasSuccessfullySensed())
    {
        DetectedPlayer = Actor;
        DetectedPlayerLocation = Stimulus.StimulusLocation;
    }
    else
    {
        DetectedPlayer = nullptr;
        DetectedPlayerLocation = FVector::ZeroVector;
    }
}

처음에는 단순 거리나 Sphere Collision으로 탐지하는 방법도 검토했지만, 현재는 시야각과 탐지 거리, 마지막으로 본 위치를 다룰 수 있는 AI Perception의 Sight를 사용한다. Behavior Tree Service는 감지 결과를 확인해 Blackboard의 IsCombat, Player, PlayerVector를 갱신한다.

플레이어를 발견하지 못한 상태에서는 NavMesh 안에서 임의의 순찰 위치를 찾는다. GetRandomReachablePointInRadius()에 적의 시작 위치와 순찰 반경을 전달하므로, 갈 수 없는 위치를 목적지로 선택하는 문제를 줄일 수 있다.

NavSystem->GetRandomReachablePointInRadius(
    EnemyCharacter->GetPatrolOrigin(),
    EnemyCharacter->GetPatrolRadius(),
    RandomLocation);

플레이어를 발견하면 Blackboard에 저장된 위치를 따라 추적하고, 공격 범위에 들어오면 근거리 또는 원거리 적의 공격 로직으로 전환한다. 장애물 회피는 NavMesh와 Character Movement의 경로 이동을 기반으로 처리하고, 세부 튜닝은 실제 맵에서 반복해서 확인할 예정이다.

근접 공격을 여러 단계의 Task로 나누기

근접 공격은 하나의 Behavior Tree Task에서 모든 과정을 처리하지 않고 세 단계로 나눴다.

  1. BTTask_SetAttackTarget: Blackboard의 Player를 적 캐릭터의 공격 대상으로 저장한다.
  2. BTTask_Attack: 적 캐릭터의 Attack()을 호출해 공격 Montage를 시작한다.
  3. BTTask_WaitAnimationFinished: Montage의 완료, 중단, 오류 상태를 확인한 뒤 Task를 끝낸다.

공격 대상 지정과 애니메이션 재생, 완료 대기를 분리하면 Behavior Tree에서 어느 단계가 실패했는지 확인하기 쉽다. 공격 방식이 다른 몬스터도 대상 선택과 대기 로직은 공유하고, 실제 공격 재생만 각 파생 클래스에서 다르게 구현할 수 있다.

근접 적인 AMeleeEnemyCharacter는 Montage의 Attack0, Attack1, Attack2 Section 중 하나를 무작위로 골라 재생한다. Montage가 끝나면 Delegate를 통해 상태를 Finished 또는 Interrupted로 변경한다.

float MontagePlayResult =
    AnimInstance->Montage_Play(MontageToPlaying, 0.1f);

AnimInstance->Montage_JumpToSection(
    GetRandomSessionName(),
    MontageToPlaying);

실제 피해는 공격 애니메이션의 타격 프레임에서 Anim Notify가 OnNotifyApplyDamage()를 호출하도록 연결했다. 공격 Task를 실행한 순간 바로 피해를 주는 것이 아니라, 몬스터의 손이나 무기가 플레이어에게 닿는 장면과 피해 시점을 맞출 수 있다.

Set Attack Target
    → Attack Montage 재생
    → Anim Notify 발생
    → OnNotifyApplyDamage()
    → Player TakeDamage()
    → Montage 종료
    → Wait Animation Finished 성공

피격과 사망 처리

적 캐릭터는 Unreal의 TakeDamage()를 오버라이드해 방어력을 적용한다. 실제 피해는 전달된 피해량에서 방어력을 빼되, 공격이 완전히 무효화되지 않도록 최소 피해량을 보장한다.

float ActualDamage = FMath::Max(
    DamageAmount - Defense,
    MinimumDamage);

Health -= ActualDamage;

if (Health <= 0)
{
    Die();
}

현재 Die()는 Actor를 제거하는 기본 처리까지 연결되어 있다. 이후에는 사망 애니메이션 재생, Collision 비활성화, 일정 시간 뒤 제거, 처치 결과를 UI와 게임 진행에 전달하는 과정을 추가해야 한다. 방어력은 현재 구조에 남겨 두고 몬스터별 Data Table이나 Stat 구조가 정리될 때 함께 확장할 계획이다.

현재까지 만들어진 플레이 흐름

현재 구현을 연결하면 다음과 같은 첫 번째 전투 흐름이 만들어진다.

레벨 시작
    → GameMode가 시작 시간 저장
    → 플레이어가 마을을 이동
    → 적이 NavMesh 영역을 순찰
    → AI Perception이 플레이어 발견
    → Blackboard가 전투 상태로 변경
    → 적이 플레이어를 추적
    → 공격 범위에서 Montage 재생
    → Anim Notify 시점에 피해 적용
    → 플레이어 사망: 게임 오버
    → 보스 룸 진입: 경과 시간 계산 후 클리어

기획 단계의 목록이 실제 클래스와 Behavior Tree로 바뀌기 시작했다는 점이 이번 작업의 가장 큰 변화다. 아직 UI와 전투의 모든 기능이 결합된 것은 아니지만, 각 담당자가 만든 기능을 연결할 수 있는 공통 진입점이 생겼다.

짧은 플레이 영상

영상은 현재까지 결합된 프로젝트의 플레이 상태를 짧게 기록한 것이다. 기획 문서에 적어 두었던 기능들이 테스트 에셋과 C++ 클래스에 머무르지 않고, 하나의 테스트 맵 안에서 실제 플레이 흐름으로 이어지기 시작한 모습을 확인할 수 있다.

현재 영상은 완성된 게임의 결과를 보여주기보다 각 담당 영역을 합치는 중간 단계에 가깝다. 플레이어 이동과 시점 조작, 마을 맵의 분위기, 적 캐릭터의 배치와 AI 반응처럼 기본 전투 환경을 우선 확인하는 데 의미가 있다. 앞으로 총기 공격과 피격 피드백, 전투 HUD, 사망 및 클리어 화면이 연결되면 같은 플레이 구간에서도 게임의 목표와 결과가 훨씬 명확하게 보일 것이다.

다음 작업

다음 단계에서는 현재 만들어진 시스템을 하나의 플레이 경험으로 연결해야 한다.

팀 프로젝트에서는 각 기능이 따로 동작하는 것만으로는 충분하지 않다. 플레이어의 공격이 적의 피격과 사망으로 이어지고, 그 결과가 UI와 게임 종료 조건에 전달되어야 한다. 다음 작업에서는 개별 기능의 완성도와 함께 이 연결 지점을 집중적으로 확인할 예정이다.