
CitRush
| 프로젝트 설명 | 전략형 레이싱 게임 |
|---|---|
| 스킬 | AI PerceptionC++GASHttpJsonNetworkStateTreeSteamSeverUMGUnreal |
| 기간 | |
| 시연 영상 | https://youtu.be/OuRkQmQcT9I |
| git | https://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
✔️ 간트차트: 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++ 커스텀 확장 가능
✔️ 구현 내용 목차
✔️ 구현 상세 내용
Enemy 기본 구성
1-1. 기본 베이스 움직임 및 전투 이벤트 처리
복잡한 LLM 서버 통신 환경에서도 끊김 없는 전투 경험을 제공하기 위해 GAS(Gameplay Ability System)와 서버 주도형 FSM, 그리고 로컬 폴백(Fallback) 시스템을 결합한 하이브리드 AI 구조를 설계
🔸기본 동작 (AbstractEnemy)
🔹기술 구현 방식
2-1. 기능 요약 설명
- Enemy 클래스 계층:
AAbstractEnemy를 기반으로 메인 적(APixelEnemy)과 무적 상태 시 소환되는 분신(ACloneEnemy)으로 분기하여 확장성 확보
- 컴포넌트 기반 설계: AI 통신, 전투 로직, 시각 효과(CCTV, 실드)를 독립된 컴포넌트로 분리하여 결합도 낮춤
컴포넌트 타입 역할 AIDirective UActorComponentLLM 서버 명령 수신 및 FSM 상태 전환 제어 Combat UActorComponent데미지/무적 처리 및 피격 시 Flash 이펙트 타임라인 관리 AbilitySystem UBaseASCGAS를 활용한 능력(Ability) 및 스탯(Attribute) 관리 SceneCapture USceneCapture2D실시간 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 후면) | Enemy | Racer.AttackPower | Event.Gameplay.Collision.Back |
| 앞에서 (Enemy → Racer) | Racer | Enemy.AttackPower | Event.Gameplay.Collision.Front |
🔹초기화 시퀀스 (BeginPlay Flow)
객체 생성 시 데이터 무결성을 위해 11단계의 엄격한 초기화 과정을 거칩니다.
- GAS 초기화:
SetReplicationMode설정 및 초기 스탯 Effect 적용
- 데이터 바인딩:
Combat,Pellet컴포넌트 이벤트 및 델리게이트 연결
- 환경 설정:
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:#fffLLM AI Sever Enemy
2-1. AI 지시(FSM) 컴포넌트 구축
AI Teeth의 고도화된 전술적 판단을 인게임 유닛 행동 표준 명령어 체계 구축
🔸행동 표준 명령어 기반 상태 전환
🔹AI FSM 컴포넌트
역할: AI 서버로부터 directive_code를 수신하여 FSM 상태로 전환하고 실행
- FSM 상태 정의 (EAIDirectiveState)
| directive_code | 상태 | 설명 |
|---|---|---|
| 0 | Idle | 대기 |
| 1 | Ambush | 매복 후 기습 |
| 2 | MoveToLocation | 지정 위치로 이동 |
| 3 | Intercept | 플레이어 차단 |
| 4 | Chase | 플레이어 추격 |
| 5 | Retreat | 후퇴 (안전지대 이동) |
| 6 | Patrol | 순찰 |
| 7 | ConsumePoint | P-Point 섭취 |
| 8 | ConsumePellet | 파워 펠릿 섭취 |
| 9 | Guard | 방어 |
| 10 | Flank | 측면 우회 |
| 11 | FakeRetreat | 기만 후퇴 (역습 포함) |
| 50 | CoinChase | 폴백 모드(코인 추적) |
| 99 | NetworkFallback | 네트워크 실패 시 |
🔹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:
AIDirectiveComponent가ProcessDirective를 호출하여 최종적으로 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 Racer | FEnemyTask_ChaseRacer | AIPerception 기반 레이서 추격 |
| Intercept Racer (EQS) | FEnemyTask_InterceptRacer | EQS로 최적 차단 위치 탐색 후 이동 |
| Consume Pellet | FEnemyTask_ConsumePellet | 가장 가까운 펠릿으로 이동 및 섭취 |
| Retreat (EQS) | FEnemyTask_Retreat | EQS로 안전 위치 탐색 후 후퇴 |
| Patrol / Coin Chase | FEnemyTask_Patrol | 순찰 (코인 우선 추적 옵션) |
| Execute Server Command | FEnemyTask_ServerCommand | AIDirectiveComponent 통해 서버 명령 실행 |
| Flank Racer (EQS) | FEnemyTask_Flank | EQS로 측면 우회 위치 탐색 |
2-3. 연동 및 서버 연결
🔹에이전트 서버 연결
| 통신 방식 | 프로토콜 | 주요용도 | 데이터 흐름 |
| Request/Response | HTTP (RESTful) | 게임 상태 전송, 리포트 결과 조회 등 확정 데이터 교환 | Client → Server (단방향 요청 후 응답) |
| Real-time Events | WebSocket | STT, TTS, 실시간 지휘관 명령 처리 등 저지연 데이터 | Client ↔ Server (양방향 실시간) |
🔹LLM/StateTree AI Enemy
LLM의 전략적 지시, StateTree의 고속 실행력을 AI 적 시스템
팩맨의 추격 메커니즘을 현대적 레이싱 장르로 재해석한 프로젝트입니다.
JSON 기반 외부 AI 서버(LLM)가 실시간 게임 상황을 분석해 전략적 명령을 내리면, 로컬의 StateTree가 프레임 단위의 고속 주행 및 전투 로직을 실행합니다.
네트워크 단절 시 즉각적인 Local Fallback을 지원하여 끊김 없는 사용자 경험을 보장합니다.
네트워크 동기화
- 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() 호출 └── 내부에서 직접 월드 탐색 수행ExecuteIdle,ExecuteChase등 각 실행 함수가 독립적으로 탐색 로직
→ 상태가 바뀔 때마다 각 함수가 중복으로 탐색 수행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)
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.02. 엔진 수치 조정 + 네트워크 (AVehicleDemoCejCar.cpp)
| 항목 | 변경 전 | 변경 후 | 이유 |
|---|---|---|---|
| MaxTorque | 5000 Nm | 1200 Nm | 질량 2500kg 대비 10배 초과 → 휠 슬립 |
| MaxRPM | 18000 | 7000 | ChangeUpRPM(6500)이 밴드 36% — 변속 타이밍 불규칙 |
| EngineRevUpMOI | 1.5 | 5.0 | 낮을수록 RPM 급상승 → 잦은 변속 → 출력 단속 |
| GearChangeTime | 0.15 s | 0.05 s | 변속 중 출력 공백 체감 제거 |
| NetUpdateFrequency | 20 Hz | 60 Hz | 50ms 갱신 간격 → 고속 주행 중 텔레포트 현상 |
🛠️ 트러블슈팅 — 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;
원인 분석
| 항목 | 내용 |
|---|---|
| 관계성 | PlayerIdAssignmentMap과 PlayerLastCommandTimeMap은 항상 동일한 키로 함께 접근됨. 논리적으로 하나의 레코드이지만 물리적으로 두 개의 해시 테이블로 분리되어 있음 |
| 해시 비용 | 한 플레이어 처리 시 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() × 2 | Empty() × 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_context | 4명 | 매 3초 Empty() 후 Add 반복 — 재할당 발생 가능 |
PlayerInfo.events | 3개 | 플레이어마다 조건부 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초마다 발생하던 힙 재할당 제거 |















