🌆

CitRush

프로젝트 설명전략형 레이싱 게임
스킬AI PerceptionC++GASHttpJsonNetworkStateTreeSteamSeverUMGUnreal
기간
시연 영상https://youtu.be/OuRkQmQcT9I
githttps://github.com/jjonjung/CitRush
성과✔️ CPU 사용률 약 40% 절감
✔️서버 장애 시, 지속적 게임 플레이 증명
✔️AI 활용 JIRA 자동화 API 구현
기여도 분석- 총 인원 5명
- 상대 기여도: 약 40%
- 분석 근거
    1. 본인 담당
        1-1. 플레이어 차량
        1-2. AI Enemy
        1-3. 서버 연동 및 검증
    2. 타 팀원 담당
        2-1. 지휘자
        2-2. 아웃게임 시스템
        2-3. 레벨디자인

✔️ 프로젝트 소개

📌

CitRush (City + Rush)

AI-게임 클라이언트 협업 프로젝트

항목내용
장르전략형 레이싱 게임
플랫폼PC (Steam 연동)
기술 스택Unreal Engine 5.6, BluePrint C++, HTTP, JSON, GAS, StateTree
담당 개발 내용- AI Sever 네트워크 통신
- AI 에이전트의 두뇌를 사용하는 Enemy AI
- Unreal StateTree 두뇌를 사용하는 Enemy AI(LLM 통신 지연 시)
- 커스텀 차량 구현

✔️ 프로젝트 시연 영상

📹

<CitRush> 시연 영상

  • 멀티 플레이
  • Enemy 상태 UI

✔️ 사용 기술 스택

  • Unreal Engine , C++ , Http

✔️ 협업 및 도구

  • Git , Notion
  • Auto Jira Api(자체 제작)
  • Google Drive (Gantt Chart)

✔️ 개발 내용

  • AI Enemy
    • Enemy 기본 구현
    • LLM Ai Sever Enemy
    • Unreal Ai Enemy

✔️ 간트차트: AI 활용 제작 JIRA 자동화 API 제작

✔️ 게임 구조

🔹게임 플레이 루프

🔹게임 시나리오

디지털 신호로 오염된 도시 "CITRUSH". 어느 날, 도시 전체의 네트워크를 타고 정체불명의 이빨이가 나타나 거리를 점령하기 시작한다. 도심 곳곳을 누비며 자신을 추적하는 자를 사냥하고, 강력한 펠렛을 삼키면 잠시 무적이 되어 최대 4기의 클론을 소환한다. 도시를 끝까지 살아남는 것이 유일한 목표다.
"도심을 점령한 디지털 존재 이빨이에게 180초를 버텨라. 레이서는 달리고, 커맨더는 지원하라.”

✔️ 핵심 기술 역량

💡

직무 기술서

요구 역량구현 사례판단 근거기술 깊이
Unreal AI 시스템 StateTree 기반 6종 전술 구현Behavior Tree 대비 가벼운 성능과 높은 상태 전환 자유도 활용StateTree Task/Evaluator 직접 제작
네트워크 최적화 JSON 데이터 최소화 및 RPC 복제대역폭 효율성과 데이터 무결성 보장 사이의 균형 설계가변 데이터 패킹 및 타임아웃 처리
C++ 시스템 설계하이브리드 AI 폴백 시스템 구축신뢰할 수 없는 외부 통신 환경에서의 클라이언트
안정성 확보
FSM 및 컴포넌트 간 비결합 설계
전투 시스템 개발 방향성 데미지 및 효과 처리확장성 있는 스킬 및 상태 이상 시스템 구축 필요GameplayTag를 활용한 상태 제어
  • Architectural Logic: 서버-클라이언트 하이브리드 AI 구조 설계 능력 보유
  • High Performance: StateTree와 최적화된 자료구조를 통한 틱 비용 최소화
  • Robustness: 네트워크 장애 대비 안정적인 복구(Fallback) 메커니즘 설계
  • Advanced Unreal: GAS 및 AI Perception 시스템의 C++ 커스텀 확장 가능

✔️ 구현 내용 목차

📌

✔️ 구현 상세 내용

1️⃣

Enemy 기본 구성

1-1. 기본 베이스 움직임 및 전투 이벤트 처리

복잡한 LLM 서버 통신 환경에서도 끊김 없는 전투 경험을 제공하기 위해 GAS(Gameplay Ability System)서버 주도형 FSM, 그리고 로컬 폴백(Fallback) 시스템을 결합한 하이브리드 AI 구조를 설계

🔸기본 동작 (AbstractEnemy)

🔹기술 구현 방식

2-1. 기능 요약 설명

  • Enemy 클래스 계층: AAbstractEnemy를 기반으로 메인 적(APixelEnemy)과 무적 상태 시 소환되는 분신(ACloneEnemy)으로 분기하여 확장성 확보
  • 컴포넌트 기반 설계: AI 통신, 전투 로직, 시각 효과(CCTV, 실드)를 독립된 컴포넌트로 분리하여 결합도 낮춤
    컴포넌트타입역할
    AIDirectiveUActorComponentLLM 서버 명령 수신 및 FSM 상태 전환 제어
    CombatUActorComponent데미지/무적 처리 및 피격 시 Flash 이펙트 타임라인 관리
    AbilitySystemUBaseASCGAS를 활용한 능력(Ability) 및 스탯(Attribute) 관리
    SceneCaptureUSceneCapture2D실시간 CCTV 렌더링 (30fps 고정 최적화)


🔹이동 방식

  • APixelEnemy에서 CharacterMovement를 MOVE_Flying으로 설정
  • GravityScale = 0.0f → 중력 없이 평면 이동
  • bConstrainToPlane = true / SetPlaneConstraintAxisSetting(Z) 
    → Z축 고정, 2D 평면 이동
  • 초기 스폰 시 SM_ROAD_19 액터의 Z좌표 + 1.0f 위치에 배치
  • MaxWalkSpeed = MaxFlySpeed = MoveSpeed (3000.f cm/s)
⚠️

멀티플레이 시, 서버 명령과 로컬 행동 간 부자연스러운 전환 이슈 발생
해결 트러블슈팅


🔹AI 서버 통신 및 의사결정 아키텍처

전역 싱글톤인 EnemyAISubsystem이 모든 적 개체를 중앙 집중식으로 관리하며,
서버 명령과 로컬 폴백을 조율합니다.

  • 서버(LLM) 주도형 FSM: 14종의 EAIDirectiveState를 통해 단순 추격이 아닌
    '매복', '기만 후퇴', '측면 우회' 등의 고수준 전술 수행
  • StateTree 폴백: 네트워크 장애 시 FEnemyTask_ChaseRacer 등 6종의
    로컬 Task가 즉시 활성화되어 AI 정지 현상 방지
⚠️

LLM 기반 AI 서버의 응답 지연으로 인한 캐릭터가 멈추거나 게임 전체 프레임이 저하되는 현상 발생
해결 트러블슈팅


🔸Pellet

🔹역할: 데미지 무적 상태, Flash 이펙트, 이벤트 브로드캐스트

🔹P-Pellet 전체 흐름

APelletActor 충돌
    └─► APixelEnemy::OnPelletCollected(Duration)
            ├─► PelletComponent->OnPelletCollected()
            │       ├─► bPowerPellet = true (Replicated)
            │       ├─► 실드 메시 표시
            │       ├─► 무적 타이머 시작 
            │       └─► 쿨다운 타이머 시작 (1초 단위 감소)
            ├─► bPowerPellet = true (레거시 플래그 동기화)
            ├─► CombatComponent->bPowerPelletActive = true
            └─► SpawnClones() (v1.5.0)
                    └─► MaxCloneCount=4 개 ACloneEnemy 스폰
                        (원형 배치, 반경 500cm)

무적 종료:
    OnInvulnerabilityEnd()
        ├─► bPowerPellet = false
        ├─► 실드 메시 숨김
        └─► DespawnClones()

  • PelletComponent

    🔹역할: P-Pellet 섭취, 무적, 쿨다운, 실드 시각화

    // 주요 설정
    float CooldownDuration;        // P-Pellet 재사용 대기 (초)
    UStaticMeshComponent* ShieldMesh;      // 실드 시각 메시 
    
    // 주요 함수
    OnPelletCollected(float Duration) // 펠릿 섭취 → 무적 타이머 시작 + 실드 
    IsPowerPelletActive()                  // 현재 무적 상태 여부
    GetCooldownRemaining()                 // 남은 쿨다운 (초)
    IsOnCooldown()                        // 쿨다운 중 여부
    
    // 델리게이트
    FOnPelletStateChanged OnPelletStateChanged     // 무적 활성/해제
    FOnPelletCooldownChanged OnPelletCooldownChanged // 쿨다운 변경

🔸GAS 연동

🔹GAS 기반 전투 시스템

: 모든 상태(체력, 속도, 무적 등)를 AttributeSet으로 관리하여 네트워크 동기화 및 유지보수성 극대화

🔹AttributeSet (UASEnemy)

// 관리 수치 목록 (모두 Replicated)
FGameplayAttributeData Health;           // 현재 체력
FGameplayAttributeData MaxHealth;        // 최대 체력
FGameplayAttributeData DetectionRange;   // 현재 감지 범위
FGameplayAttributeData DefaultDetectionRange;
FGameplayAttributeData Speed;            // 현재 이동 속도
FGameplayAttributeData DefaultSpeed;
FGameplayAttributeData AttackPower;      // 현재 공격력
FGameplayAttributeData DefaultAttackPower;

🔹GAS Ability 부여 방식

  • 서버 전용 (HasAuthority() 체크)
// AbstractEnemy::ReceiveAbility()
FGameplayAbilitySpec abilitySpec(ability);
abilitySystemComponent->GiveAbility(abilitySpec);
  • Enemy 전용 GameplayAbilities
어빌리티설명
GA_PixelAttack공격 어빌리티,
쿨다운
GA_PixelChase추격 행동
GA_PixelPatrol순찰 행동
GA_PixelEscape후퇴 행동
GA_Earthquake특수 공격

🔸충돌 방향별 데미지 이벤트 처리

🔹충돌 감지 경로

CapsuleComponent::OnComponentHit
    └─► APixelEnemy::OnHitToRacer()
            ├─► ACoinActor   → 코인 획득 처리
            ├─► APelletActor → 펠릿 획득 처리
            └─► AAbstractRacer → 충돌 방향 판정 후 데미지 이벤트

OnOverlapToRacer()도 동일 로직으로 처리 (Sweep 충돌 대비)

🔹충돌 방향 판정 로직 (AbstractEnemy::OnCollisionHit)

: 단순 충돌이 아닌 DotProduct를 활용한 방향성 데미지 판정으로 전략적 전투 요소 추가

FVector hitDirection = (hitLocation - EnemyLocation).GetSafeNormal();
float dotProduct = FVector::DotProduct(GetActorForwardVector(), hitDirection);

if (dotProduct < 0.0f)  // 뒤에서 충돌 → Enemy가 맞음
{
    EventTag = "Event.Gameplay.Collision.Back"
    damagedASC = Enemy ASC
    EventData.EventMagnitude = Racer.AttackPower  
											        // 레이서의 공격력으로 피해
}
else  // 앞에서 충돌 → Racer가 맞음
{
    EventTag = "Event.Gameplay.Collision.Front"
    damagedASC = Racer ASC
    EventData.EventMagnitude = Enemy.AttackPower  
                               // 적의 공격력으로 피해
}

damagedASC->HandleGameplayEvent(EventTag, &EventData);
충돌 방향피해 대상사용 공격력GameplayTag
뒤에서 
(Racer → Enemy 후면)
EnemyRacer.AttackPowerEvent.Gameplay.Collision.Back
앞에서 
(Enemy → Racer)
RacerEnemy.AttackPowerEvent.Gameplay.Collision.Front

🔹초기화 시퀀스 (BeginPlay Flow)

객체 생성 시 데이터 무결성을 위해 11단계의 엄격한 초기화 과정을 거칩니다.

  1. GAS 초기화: SetReplicationMode 설정 및 초기 스탯 Effect 적용
  1. 데이터 바인딩: Combat, Pellet 컴포넌트 이벤트 및 델리게이트 연결
  1. 환경 설정: MOVE_Flying 고정(Z축 고정), 미니맵 태그 추가, CCTV 렌더링 개시

🔹피해 처리 흐름

graph LR
      Start(["적이 데미지를 받음"])

      CheckPP{"무적"}
      CheckUT{"무적 쿨타임"}
      Ignore["데미지 무시"]

      ApplyDmg["체력 감소"]
      CheckHP{"체력 ≤ 0"}
      Death["파괴 연출 재생"]

      CheckEffect{"피격 이펙트 에셋"}
      SpawnEffect["피격 이펙트 재생"]
      SkipEffect["이펙트 없음"]

      CheckCombat{"전투 컴포넌트"}
      NewInvuln["무적 쿨타임 시작"]
      LegacyInvuln["무적 활성화"]

      Start --> CheckPP
      CheckPP -- 예 --> Ignore
      CheckPP -- 아니오 --> CheckUT
      CheckUT -- 예 --> Ignore
      CheckUT -- 아니오 --> ApplyDmg
      ApplyDmg --> CheckHP
      CheckHP -- "예 (사망)" --> Death
      CheckHP -- "아니오 (생존)" --> CheckEffect
      CheckEffect -- 예 --> SpawnEffect
      CheckEffect -- 아니오 --> SkipEffect
      SpawnEffect --> CheckCombat
      SkipEffect --> CheckCombat
      CheckCombat -- 예 --> NewInvuln
      CheckCombat -- 아니오 --> LegacyInvuln

      style Start fill:#222,stroke:#888,color:#fff
      style Ignore fill:#1a1a1a,stroke:#444,color:#888
      style CheckPP fill:#4d2600,stroke:#ff922b,color:#fff
      style CheckUT fill:#4d2600,stroke:#ff922b,color:#fff
      style ApplyDmg fill:#1e2a38,stroke:#4a90e2,color:#fff
      style CheckHP fill:#3d2b00,stroke:#d4af37,color:#fff
      style Death fill:#3a1f1f,stroke:#ff6b6b,color:#fff
      style CheckEffect fill:#2a2a2a,stroke:#aaaaaa,color:#fff
      style SpawnEffect fill:#1b2e1b,stroke:#51cf66,color:#fff
      style SkipEffect fill:#1a1a1a,stroke:#444,color:#888
      style CheckCombat fill:#2a2a2a,stroke:#aaaaaa,color:#fff
      style NewInvuln fill:#1b2e1b,stroke:#51cf66,color:#fff
      style LegacyInvuln fill:#1b2535,stroke:#4ecdc4,color:#fff
2️⃣

LLM AI Sever Enemy

2-1. AI 지시(FSM) 컴포넌트 구축

AI Teeth의 고도화된 전술적 판단을 인게임 유닛 행동 표준 명령어 체계 구축

🔸행동 표준 명령어 기반 상태 전환


🔹AI FSM 컴포넌트

역할: AI 서버로부터 directive_code를 수신하여 FSM 상태로 전환하고 실행

  • FSM 상태 정의 (EAIDirectiveState)
directive_code상태설명
0Idle대기
1Ambush매복 후 기습
2MoveToLocation지정 위치로 이동
3Intercept플레이어 차단
4Chase플레이어 추격
5Retreat후퇴 (안전지대 이동)
6Patrol순찰
7ConsumePointP-Point 섭취
8ConsumePellet파워 펠릿 섭취
9Guard방어
10Flank측면 우회
11FakeRetreat기만 후퇴 (역습 포함)
50CoinChase폴백 모드(코인 추적)
99NetworkFallback네트워크 실패 시


🔹AI 전체 아키텍처

GameInstance
    └── UEnemyAISubsystem (전역 싱글톤)
            ├── HTTP 클라이언트 (단일 연결)
            ├── RegisteredEnemies: TArray<IAIDecisionReceiver*>
            │       ├── APixelEnemy (ID: "pacman_main")
            │       └── ACloneEnemy (ID: "clone_1" ~ "clone_4")
            ├── DecisionTimer (자동 요청 타이머, 기본 3초 간격)
            ├── 액터 캐시 (GetAllActorsOfClass 대체)
            │       ├── CachedVehicleArray
            │       ├── CachedPelletArray
            │       ├── CachedCoinArray
            │       └── CachedPelletSpawnerArray
            └── Request 순서 관리 (request_num)

🔹상세 통신 및 처리 시퀀스

  • Response Handling: EnemyAISubsystem이 서버로부터 JSON 응답을 수신
  • Order Validation: ValidateResponseOrder()를 통해 현재 request_num보다 낮은(오래된) 응답은 폐기하여 최신 명령의 무결성을 보장
  • Command Dispatch: 인터페이스(IAIDecisionReceiver)를 통해 각 적(Enemy) 개체에게 명령(FUnitCommand)
  • Data Translation: FUnitCommandParams를 Enemy가 이해할 수 있는 FDirectiveParams 구조체로 변환
  • State Transition: AIDirectiveComponentProcessDirective를 호출하여 최종적으로 14종의 FSM 상태(EAIDirectiveState) 중 하나로 전환

  • 응답 처리 및 상태 전환
    // EnemyAISubsystem에서 개별 Enemy로 명령 전달
    void UEnemyAISubsystem::HandleDecisionResponse(const FString& ResponseContent)
    {
        // 1. 순서 검증 (Sequence Check)
        if (!ValidateResponseOrder(CurrentResponseNum)) return;
    
        // 2. 각 RegisteredEnemies에게 명령 분배
        for (auto* Enemy : RegisteredEnemies)
        {
            Enemy->OnAIDecisionReceived(ParsedCommand);
        }
    }
    
    // AIDirectiveComponent에서 최종 FSM 상태 전환
    void UAIDirectiveComponent::ProcessDirective(int32 Code, const FDirectiveParams& Params)
    {
        EAIDirectiveState NewState = static_cast<EAIDirectiveState>(Code);
        
        // 상태 전환 및 매개변수 적용
        TransitionToState(NewState, Params);
    }

  • ProcessDirective 전환 매핑
    void ProcessDirective(int32 DirectiveCode, const FDirectiveParams& Params){
        EAIDirectiveState NewState = static_cast<EAIDirectiveState>(DirectiveCode);
        TransitionToState(NewState, Params);
    }
    
    void TransitionToState(EAIDirectiveState NewState, const FDirectiveParams& Params){
        // 이전 상태 저장
        EAIDirectiveState OldState = CurrentState;
    
        // 상태 전환
        CurrentState = NewState;        
        // Replicated → 자동으로 클라이언트 동기화
        CurrentParams = Params;
        ReplicatedTargetPosition = Params.TargetPosition;
        StateStartTime = GetWorld()->GetTimeSeconds();
    
        // GAS State Tag 적용 (옵션)
        if (ASC) ASC->AddLooseGameplayTag(GetStateTag(NewState));
        // 이벤트 브로드캐스트
        OnAIStateChanged.Broadcast(NewState, OldState);
    }

🔸네트워크 복제

🔹클라이언트 동기화 흐름


🔸타임아웃 처리

🔹단계별 타임아웃 처리

단계조건처리
Goal Stale현재시간 - LastDirectiveTime > GoalMaxAge(7초)IsGoalStale() = true 
→ 다음 명령 대기 중 무효화
연속 실패HTTP 실패 2회(DisconnectThreshold)OnConnectionChanged(false) 
→ ActivateLocalAI()
422 에러HTTP 422 응답마지막 성공 응답 재사용(LastSuccessfulResponse)
재연결HTTP 성공OnConnectionChanged(true) 
→ DeactivateLocalAI()

🔹폴백 응답 생성 (CreateFallbackAttackResponse)

// HTTP 실패 시 기본 공격 명령 생성 
FGetDecisionResponse2 CreateFallbackAttackResponse()

🔹요청 순서 보장

: request_num 응답 검증으로 값이 일치할 때만 처리로 연결 지연 있을 경우 llm 응답 순서 혼동 상황 제어

// request_num 형식: {room_id}_{timestamp}_{sequence}
FString GenerateRequestNum(RoomID)

// 응답 검증: LastSentRequestNum과 일치할 때만 처리
bool ValidateResponseOrder(IncomingRequestNum)
    if (incoming == LastSent) // → 처리 허용
    if (incoming < LastSent)  // → 무시 (오래된 응답)

🔹AI 서버 통신 요약

[자동 요청 루프 - 기본 3초]
EnemyAISubsystem::SendBatchDecisionRequest()
    ├── 현재 게임 상태 수집
    │     ├── APixelEnemy::GetCurrentGameState()
    │     │     ├── position, health, speed
    │     │     ├── capture_gauge (위협도)
    │     │     ├── is_invulnerable (펠릿 + 전투 무적)
    │     │     ├── p_pellet_cooldown
    │     │     └── forward_vector, rotation_yaw
    │     └── 레이서 위치/속도 정보
    │
    ├── request_num 생성 및 LastSentRequestNum 저장
    │
    ├── HTTP POST /api/v1/get_decision
    │
    └── 응답 처리
          ├── ValidateResponseOrder() 검증
          ├── units[] 배열에서 각 Enemy ID 매핑
          └── IAIDecisionReceiver::OnAIDecisionReceived() 호출

2-2. Enemy 구성 연동 및 서비스 끊김 시 StateTree

하이브리드 AI 구조
:
LLM 서버의 전략적 명령과 StateTree기반의 즉각적인 로컬 대응을 결합하여 지연 시간 극복

🔸AI Perception

🔹설정 구조체 (FEnemyPerceptionConfig)

bool bEnableSight     // Sight 감각 활성화
float SightRadius     // 시야 범위 (cm)
float LoseSightRadius // 시야 상실 범위
float PeripheralVisionAngle // 주변 시야 각도 (도)
bool bEnableHearing   // Hearing 감각 활성화
float HearingRange    // 청각 범위 (cm)
bool bEnableDamage   // Damage 감각 활성화 (피격 시 자동 감지)
float MaxStimuliAge   // 자극 유효 시간

TSubclassOf<UAISense> DominantSense //지배 감각 (기본: Sight)
FAISenseAffiliationFilter SightAffiliationFilter
FAISenseAffiliationFilter HearingAffiliationFilter

🔹타임아웃 처리

float GoalMaxAge;  // GoalMaxAge(초) 이상 새 명령 없으면 Goal Stale 처리
bool IsGoalStale() // → (현재시간 - LastDirectiveTime) > GoalMaxAge

🔸StateTree (로컬 AI 폴백)

🔹활성화 조건: AI 서버 연결 끊김 시 자동 전환

🔹구현된 Task 목록 (6종 + 추가)

Task Struct설명
Chase RacerFEnemyTask_ChaseRacerAIPerception 기반 레이서 추격
Intercept Racer (EQS)FEnemyTask_InterceptRacerEQS로 최적 차단 위치 탐색 후 이동
Consume PelletFEnemyTask_ConsumePellet가장 가까운 펠릿으로 이동 및 섭취
Retreat (EQS)FEnemyTask_RetreatEQS로 안전 위치 탐색 후 후퇴
Patrol / Coin ChaseFEnemyTask_Patrol순찰 (코인 우선 추적 옵션)
Execute Server CommandFEnemyTask_ServerCommandAIDirectiveComponent 통해 서버 명령 실행
Flank Racer (EQS)FEnemyTask_FlankEQS로 측면 우회 위치 탐색

2-3. 연동 및 서버 연결

🔹에이전트 서버 연결

통신 방식프로토콜주요용도데이터 흐름
Request/ResponseHTTP
(RESTful)
게임 상태 전송, 리포트 결과 조회 등 확정 데이터 교환Client → Server
(단방향 요청 후 응답)
Real-time EventsWebSocketSTT, TTS, 실시간 지휘관 명령 처리 등 저지연 데이터Client ↔ Server
(양방향 실시간)

🔹LLM/StateTree AI Enemy

LLM의 전략적 지시, StateTree의 고속 실행력을 AI 적 시스템
팩맨의 추격 메커니즘을 현대적 레이싱 장르로 재해석한 프로젝트입니다.
JSON 기반 외부 AI 서버(LLM)가 실시간 게임 상황을 분석해 전략적 명령을 내리면, 로컬의 StateTree가 프레임 단위의 고속 주행 및 전투 로직을 실행합니다.
네트워크 단절 시 즉각적인 Local Fallback을 지원하여 끊김 없는 사용자 경험을 보장합니다.

3️⃣

레이서의 아이템 사용 및 피격

카메라 FOV 확장모션 블러(Motion Blur)PP Material
UcameraComponetFOV(Field of view)값을 동적으로 증가시켜 시야를 왜곡하고 속도감 강조Post Process VolumeMotion Blur 강도를 실시간으로 저절하여 주변 사물이 빠르게 스쳐 지나가는 효과 연출화면 외곽에 속도선을 그리는 커스텀 포스트 프로세스 메티리얼의 파라미터를 C++에서 제어하여 효과를 활성화

🌐

네트워크 동기화

  • RPC 흐름 이해 및 Replication 범위 최소화
  • 상태 동기화 설계 및 서버 기준 판정 사고
  • 네트워크 불안정성을 고려한 Resilient Design(회복 탄력성 설계)
✔️

네트워크 및 동기화 전략

  • RPC & Replication: directive_code와 핵심 파라미터만을 패킹하여 복제함으로써 대역폭 소모를 최소화.
  • Prediction: 로컬 서버 끊김 시점에도 AI가 관성에 따라 주행하며 로컬 로직으로 부드럽게 전환(Smooth Transition)되도록 보간 로직 적용.
항목기존
(단일 FSM/서버 의존)
개선
(StateTree + Fallback)
반응 지연
(Response Latency)
수백 ms
(서버 왕복 시간)
즉각 대응
(프레임 단위)
행동 다양성단순 추격 2종전술 행동 6종
(플랭크, 차단 등)
CPU 비용 (상태 체크)O(N^2)
(복합 조건문)
O(1)
(StateTree 트리 구조 최적화)
네트워크 가용성서버 단절 시 AI 작동 중지로컬 폴백으로 StateTree 사용하여 100% 가동률 유지
  • CPU 비용 개선
    항목기존 개선
    조건 평가 방식상태 전환마다 월드 탐색 반복 호출Evaluator가 1틱 1회 캐싱 → 조건은 O(1) 비교
    실제 복잡도O(K × N) 조건 K개 × 오브젝트 N개O(N) 캐싱 1회 + O(1) 조건 평가
    시간 복잡도 O(N²) O(1)

    1. 기존 방식 - FSM + AIDirectiveComponent

    TickComponent (0.1f 간격)
    └── UpdateCurrentState(DeltaTime)
        └── switch(CurrentState) → ExecuteX() 호출
            └── 내부에서 직접 월드 탐색 수행
    TickComponent (0.1f 간격)
    └── UpdateCurrentState(DeltaTime)
        └── switch(CurrentState) → ExecuteX() 호출
            └── 내부에서 직접 월드 탐색 수행
    함수 내에서 탐색 수행

    ExecuteIdleExecuteChase 등 각 실행 함수가 독립적으로 탐색 로직
    → 상태가 바뀔 때마다 각 함수가 중복으로 탐색 수행

    이 조건이 StateTree에 조건이 여러 개 있고 에너미도 N마리 있으면
    : 에너미 N × 조건 K × 펠릿 M = O(N × K × M) 가 됨

    2. 개선 후 (StateTree 트리 구조 최적화)

    FEnemyContextEvaluator::Tick() - 1틱에 1회만 실행
    └── 모든 거리 계산 → FEnemyStateTreeContextData 에 저장
    ├── bIsServerConnected
    ├── NearestRacerDistance
    ├── NearestPelletDistance
    ├── NearestCoinDistance
    ├── HealthRatio
    └── bHasPowerPellet
    
    StateTree 조건 평가 (캐싱된 값 참조)
    ├── FEnemyCondition_ServerConnected 
    |       → bIsServerConnected 읽기 = O(1)
    ├── FEnemyCondition_HasNearbyRacer  
    |       → NearestRacerDistance 비교 = O(1)
    ├── FEnemyCondition_LowHealth       → HealthRatio 비교 = O(1)
    └── FEnemyCondition_HasPowerPellet  
              → bHasPowerPellet 읽기 = O(1)
    Evaluator Tick - 1번만 계산하고 저장

🧱

OOP 설계

구조 설계 원칙

1. 단일 책임 원칙 (SRP)

AIDirective, Perception, Combat, Pellet처럼 기능을 컴포넌트로 분리해 각 요소가 하나의 책임만 담당하도록 설계

2. 조합 중심 설계

PixelEnemy는 상속 확장보다 필요한 컴포넌트를 조합하는 Composition over Inheritance 구조로 설계

3. 이벤트 기반 연결

  • 컴포넌트 간 직접 참조를 줄이고 이벤트와 Delegate로 느슨하게 연결
Directive → State 변경 요청
Combat → 이벤트 발행
void UAIServerComponent::RequestDecision() {
    if (GetWorld()->GetTimeSeconds() - LastRequestTime < 2.5f) return;

    FHttpRequestRef Request = FHttpModule::Get().CreateRequest();
    Request->OnProcessRequestComplete().BindUObject(this, &UAIServerComponent::OnResponseReceived);
    Request->SetURL(ServerURL);
    Request->SetVerb(TEXT("POST"));
    Request->ProcessRequest();
    LastRequestTime = GetWorld()->GetTimeSeconds();
}

void UAIServerComponent::OnResponseReceived(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful) {
    if (bWasSuccessful && Response->GetResponseCode() == 200) {
        ApplyAIDecision(ParseJSON(Response->GetContentAsString()));
    } else {
        // 응답 실패 또는 지연 시 로컬 FSM 가동
        ExecuteFallbackAI();
    }
}

1-2. 전략 개선: 상태 전환 블렌딩 (Blending)

서버 응답이 도착했을 때, 현재 수행 중인 로컬 행동과 서버의 새로운 명령 사이의 간극을 줄이기 위해 보간(Interpolation) 로직을 추가했습니다.

// AI 상태 전환 Blending 처리
void UAIServerComponent::BlendToServerDecision(FAIDecision NewDecision) {
    // 현재 행동과 새로운 명령 사이의 전환 시간 0.5초
    GetOwner()->GetMovementComponent()->SmoothTransition(NewDecision, 0.5f);
}
✔️

실행 및 성과 증명

2-1. 행동 및 구현 내용

StateTree 기반 로컬 태스크 구현추격, 차단, 후퇴, 순찰 등 6종의 기본 행동을 StateTree로 구축하여 서버 응답 전까지 자연스러운 움직임 유지
Perception
기반 타겟 갱신
코인 및 레이서 감지 시 즉시 블랙보드를 업데이트하여 반응 속도 최적화
전투 피드백
시스템
GAS(Gameplay Ability System)를 활용하여 타격 시 플래시 효과, 쿨다운, 이펙트가 네트워크 동기화와 별개로 클라이언트에서 즉시 반응하도록 구현

2-2. 정량적/기술적 성과

성능 최적화비동기 전환 및 Throttling 적용으로 AI 관련 CPU 사용률 약 40% 절감
프레임 안정성서버 통신 중에도 클라이언트 메인 루프 프레임100% 유지
가용성 확보서버 장애 또는 네트워크 지연 시에도 폴백 시스템을 통해 게임 플레이 지속 가능성 증명

🔧

서버 명령과 로컬 행동 간 부자연스러운 전환

🚫

문제 상황: 서버 응답이 늦게 도착했을 때 급격한 애니메이션/위치 변화 발생

해결 방법:

상태 전환 블렌딩 로직 추가 - 0.5초 보간(Interpolation)으로 부드러운 전환 처리

void UAIServerComponent::BlendToServerDecision(FAIDecision NewDecision) {
    GetOwner()->GetMovementComponent()->SmoothTransition(NewDecision, 0.5f);
}

성과: 시각적 안정성 확보, 플레이어 경험 개선

🔧

차량 움직임이 부드럽지 않은 문제

🚫

문제 상황

증상발생 조건
방향 전환 시 카메라가 뒤늦게 따라와 화면이 뭉툭하게 반응항상
카메라가 전방 방향으로 너무 천천히 복귀코너링 후 직진 복귀 시
저속 가속 시 차가 갑자기 튀어나가는 현상출발 / 재가속 시
가속 중 속도가 일정하지 않고 미세하게 출렁임중속 구간 주행 시
멀티플레이에서 다른 플레이어 차량이 텔레포트처럼 끊겨 보임3인 이상 멀티 세션
변속 타이밍에 순간적인 출력 끊김 느낌가속 중 자동변속 발생 시
⚠️

원인 분석

① 카메라 이중 보간② 물리 파라미터 불균형③ 네트워크 업데이트 빈도
현상
파악
방향 전환 시 카메라 지연·복귀 느림출발 급발진, 가속 중 속도 출렁임, 변속 끊김멀티에서 다른 차량 텔레포트처럼 끊김
분석
방법
부모 클래스 AUE_CITRUSHPawn 추적 →
bEnableCameraRotationLag(Speed 2.0) +
Tick FInterpTo(Speed 1.0) 이중 보간 확인
MaxTorque 5000 Nm / 2500 kg
= 2.0 Nm/kg (실차 미니쿠퍼 0.2의 10배)
EngineRevUpMOI 1.5f → RPM 급상승
ChangeUpRPM 6500 / MaxRPM 18000 = 밴드 36%에서만 변속
20 Hz(50ms 갱신) 상태에서
200 km/h 주행 시 갱신마다 2~3m 위치 점프
3인 × 60 Hz = 180 pkt/s → 부하 미미
전략적
판단
커스텀 카메라 재작성도 가능했으나
기존 구조 유지 + 보간 속도 조정으로
사이드이펙트 없이 해결
PID 적응형 토크 제어도 가능했으나
물리 기반 수치 재산정으로
Chaos Vehicle 시뮬레이션 그대로 활용
CSP 구현도 가능했으나
차량 물리 CSP는 불일치 처리 복잡도가 높음
→ 60 Hz 상향으로 단순하게 해결

해결

✔️

1. 카메라 이중 보간 제거 (AUE_CITRUSHPawn.cpp)

// SpringArm 회전 보간 + Tick 수동 보간이 겹쳐 이중 지연이 발생하고 있었음
BackSpringArm->CameraRotationLagSpeed = 8.0f;  // 기존 2.0
BackSpringArm->bEnableCameraLag       = true;
BackSpringArm->CameraLagSpeed         = 5.0f;

CameraYaw = FMath::FInterpTo(CameraYaw, 0.0f, Delta, 3.0f); // 기존 1.0

2. 엔진 수치 조정 + 네트워크 (AVehicleDemoCejCar.cpp)

항목변경 전변경 후이유
MaxTorque5000 Nm1200 Nm질량 2500kg 대비 10배 초과 → 휠 슬립
MaxRPM180007000ChangeUpRPM(6500)이 밴드 36% — 변속 타이밍 불규칙
EngineRevUpMOI1.55.0낮을수록 RPM 급상승 → 잦은 변속 → 출력 단속
GearChangeTime0.15 s0.05 s변속 중 출력 공백 체감 제거
NetUpdateFrequency20 Hz60 Hz50ms 갱신 간격 → 고속 주행 중 텔레포트 현상

🛠️ 트러블슈팅 — AI 서버 전송 자료구조 최적화

대상 파일: EnemyAISubsystem.cpp / .h  |  대상 함수: SendBatchDecisionRequest(), SendMatchStartToServer(), StartMatch()
AI 서버에 플레이어 위치·상태를 전송하는 핵심 함수가 3초 간격 타이머로 반복 호출되면서, 자료구조 선택에 따른 누적 오버헤드가 발생하였음.
🔧

TMap 2개 분리 — 같은 키에 대한 이중 해시 연산

🚫

문제 상황

증상 발생 조건
같은 UEPlayerId 키로 두 TMap을 항상 동시에 읽고 씀플레이어 루프 매 호출
Contains → Add → 배열접근 → Find 순서로 최대 4회 해시 연산 발생플레이어당 매 3초
두 Map이 각각 독립적인 해시 버킷을 유지 → 메모리 이중 소비상시
StartMatch 리셋 시 .Empty() 2번 호출매치 시작 시

변경 전 코드

// 헤더에 별개로 선언 — 같은 키인데 Map이 2개
TMap<int32, int32>   PlayerIdAssignmentMap;    // UE ID → 순번
TMap<int32, FString> PlayerLastCommandTimeMap; // UE ID → 시각

// 사용 시 항상 두 번 조회 (최대 4회 해시 연산)
if (!PlayerIdAssignmentMap.Contains(UEPlayerId))          // ① Contains
{
    PlayerIdAssignmentMap.Add(UEPlayerId, PlayerIdCounter); // ② Add
    PlayerLastCommandTimeMap.Add(UEPlayerId, InitTime);     // ③ Add
}
int32 AssignedId = PlayerIdAssignmentMap[UEPlayerId];      // ④ 배열접근

if (FString* Mapped = PlayerLastCommandTimeMap.Find(UEPlayerId)) // ⑤ Find
    LastCmdTime = *Mapped;
⚠️

원인 분석

항목 내용
관계성PlayerIdAssignmentMapPlayerLastCommandTimeMap은 항상 동일한 키로 함께 접근됨. 논리적으로 하나의 레코드이지만 물리적으로 두 개의 해시 테이블로 분리되어 있음
해시 비용한 플레이어 처리 시 Contains(1) + Add×2(2) + 배열접근(1) + Find(1) = 5회 해시 연산. 플레이어 4명이면 20회
메모리TMap은 내부적으로 해시 버킷 배열 + 링크드 리스트를 유지. 2개 선언 시 버킷 관리 비용 2배
중복 패턴SendBatchDecisionRequest, SendMatchStartToServer, StartMatch 3개 함수에 동일한 패턴 반복 → 유지보수 비용 증가

해결 — FPlayerEntry 구조체로 통합 + FindOrAdd

// 헤더: 두 필드를 하나의 구조체로 통합
struct FPlayerEntry
{
    int32 AssignedId = 0;
    FString LastCommandTime;
};
TMap<int32, FPlayerEntry> PlayerEntryMap;

// 사용: FindOrAdd로 1회 해시 연산
FPlayerEntry& Entry = PlayerEntryMap.FindOrAdd(UEPlayerId); // ① 1회
if (Entry.AssignedId == 0)
{
    Entry.AssignedId = ++PlayerIdCounter;
    Entry.LastCommandTime = InitTime;
}
int32 AssignedId = Entry.AssignedId; // 이미 갖고 있는 참조 — 추가 연산 없음
항목 변경 전 변경 후
Map 개수2개1개
플레이어당 해시 연산최대 5회1회 (FindOrAdd)
리셋 코드Empty() × 2Empty() × 1
🔧

플레이어 루프 내 GetLLMData() 반복 호출 + O(n×m) 중첩 탐색

🚫

문제 상황

증상 발생 조건
DRIVER 플레이어마다 GetLLMData() 호출 → FNavSystemLLMData 구조체 복사 생성DRIVER가 3명이면 3회 복사
각 DRIVER마다 RacerData 배열을 처음부터 순회 (O(m) 탐색)플레이어 루프 매 호출
전체 복잡도: O(플레이어 수 × RacerData 크기)매 3초

변경 전 코드

// 플레이어 루프 내부: DRIVER마다 반복 실행
for (FConstPlayerControllerIterator Iterator = ...; Iterator; ++Iterator)
{
    if (PlayerInfo.player_type == TEXT("DRIVER") && NavDataComponent)
    {
        // ❌ DRIVER마다 구조체 값 복사 발생 (TArray 내부 힙 포함)
        FNavSystemLLMData NavData = NavDataComponent->GetLLMData();

        bool bFoundMatch = false;
        for (const FRacerNavigationData& RacerData : NavData.RacerData) // ❌ O(m) 탐색
        {
            if (RacerData.PlayerIndex == PlayerArrayIndex)
            {
                // 데이터 복사 후 break
                bFoundMatch = true; break;
            }
        }
    }
}
⚠️

원인 분석

항목 내용
구조체 복사 비용GetLLMData()FNavSystemLLMData값으로 반환. RacerData(TArray)를 포함하므로 복사 시 힙 할당 포함. DRIVER 3명 기준 3회 발생
탐색 복잡도플레이어마다 RacerData 배열 선형 탐색 → O(플레이어 수 × RacerData 크기). 데이터가 동일한데 매번 새로 순회
중복 계산CalculateNavigationData()는 루프 전 이미 1회 호출됨. 같은 프레임 데이터를 플레이어마다 다시 가져오는 것은 낭비
누적 영향3초마다 호출되므로 단발성이 아닌 지속 오버헤드. 플레이어 수가 늘수록 선형 증가

해결 — 플레이어 루프 진입 전 TMap 1회 빌드

// ✅ 플레이어 루프 진입 전, 딱 1회 GetLLMData() + TMap 빌드
TMap<int32, FRacerNavigationData> RacerNavMap;
if (NavDataComponent)
{
    FNavSystemLLMData PrebuiltNavData = NavDataComponent->GetLLMData(); // 1회만
    RacerNavMap.Reserve(PrebuiltNavData.RacerData.Num());
    for (const FRacerNavigationData& RD : PrebuiltNavData.RacerData)
        RacerNavMap.Add(RD.PlayerIndex, RD); // PlayerIndex → 데이터 O(1) 매핑
}

// ✅ 플레이어 루프 내부: O(1) 조회
if (const FRacerNavigationData* RacerData = RacerNavMap.Find(PlayerArrayIndex))
{
    PlayerInfo.racer_nav_delta.player_index          = RacerData->PlayerIndex;
    PlayerInfo.racer_nav_delta.delta_straight_distance = RacerData->DeltaStraightDistance;
    // ...
}
항목 변경 전 변경 후
GetLLMData() 호출1 + DRIVER 수 (최대 4회)고정 2회
구조체 복사DRIVER마다 발생1회 고정
RacerData 탐색O(플레이어 × RacerData)O(플레이어) — 조회 O(1)
🔧

TArray Reserve 누락 — 상한이 확정된 배열에서 반복 힙 재할당

🚫

문제 상황

배열 상한 문제
player_team_context4명매 3초 Empty() 후 Add 반복 — 재할당 발생 가능
PlayerInfo.events3개플레이어마다 조건부 Add — 최대 1~3회 재할당 가능
ValidNavCosts플레이어 수임시 배열로 매 호출 생성 후 Add
⚠️

원인 분석

항목 내용
TArray 재할당 메커니즘용량 초과 시 현재 용량의 약 2배로 힙 재할당. 새 블록 할당 → 기존 원소 복사 → 구 블록 해제 순으로 진행
상한 인지 여부플레이어 수(최대 4), 이벤트 종류(FUEL_LOW / HP_LOW / RISK_HIGH = 3)는 게임 디자인상 상한이 고정되어 있어 미리 Reserve 가능
누적 영향SendBatchDecisionRequest는 3초마다 호출 → player_team_context가 매번 빈 상태에서 시작해 재할당 반복. 소규모이나 지속 발생

해결 — 상한 확정 후 Reserve 선언

// ✅ 플레이어 배열: 최대 4명 고정
Request.player_team_context.Empty();
Request.player_team_context.Reserve(4);

// ✅ 이벤트 배열: FUEL_LOW / HP_LOW / RISK_HIGH 최대 3개
PlayerInfo.events.Empty();
PlayerInfo.events.Reserve(3);

// ✅ NavCost 임시 배열: 이미 확정된 크기 사용
TArray<float> ValidNavCosts;
ValidNavCosts.Reserve(Request.player_team_context.Num());

Reserve는 힙을 미리 확보하여 이후 Add 시 재할당을 방지. 상한이 고정된 배열에 적용 시 추가 비용 없이 재할당을 제거하는 가장 단순한 방법.

📊

개선 테스트 방법

방법 정밀도 사용 시기
Unreal Insights + TRACE_CPUPROFILER_EVENT_SCOPE높음 (함수 단위 ms)정확한 Before/After 비교
SCOPE_CYCLE_COUNTER + stat Game중간실시간 콘솔 모니터링
stat unit 콘솔 명령낮음 (전체 Frame)전체 이상 유무 확인
FPlatformTime::Seconds() 수동 루프중간빠른 일회성 로그 확인

측정 포인트: SendBatchDecisionRequest는 AI 서버 커맨드 수신마다 호출됨. Unreal Insights로 누적 Avg/Max ms를 변경 전 브랜치와 비교하는 것이 가장 명확함.

// Unreal Insights 측정용 (확인 후 제거)
#include "ProfilingDebugging/CpuProfilerTrace.h"

void UEnemyAISubsystem::SendBatchDecisionRequest()
{
    TRACE_CPUPROFILER_EVENT_SCOPE(EnemyAI_SendBatchDecisionRequest);
    // ...
}

// 또는 빠른 로그 확인용 (확인 후 반드시 제거)
double Start = FPlatformTime::Seconds();
// ... 기존 코드 ...
UE_LOG(LogEnemyAI, Warning, TEXT("[Perf] SendBatch: %.4f ms"),
    (FPlatformTime::Seconds() - Start) * 1000.0);

📋 전체 개선 요약

문제 원인 해결책 기대 효과
TMap 2개 분리같은 키로 두 Map 동시 접근FPlayerEntry 구조체로 통합해시 연산 5회 → 1회, 메모리 절반
GetLLMData() 반복 복사플레이어 루프 내부에서 호출루프 전 1회 TMap으로 사전 빌드구조체 복사 최대 4회 → 2회 고정
RacerData O(n×m) 탐색플레이어마다 선형 탐색TMap.Find()로 O(1) 조회O(플레이어×RacerData) → O(플레이어)
TArray 반복 재할당Reserve 없이 Add 사용상한 확정 후 Reserve 선언3초마다 발생하던 힙 재할당 제거