포스트

구현계획 — 8번과제

구현계획 — 8번과제

8번 과제 구현 계획 — Void Tier 0 프로토타입 (TPS 피벗)

작성일: 2026-04-20 최종 수정: 2026-04-28 — Day 4 진입 + 마감 1차 연장(샷건+반동 필수 합산) + 팀플 스캐폴딩 전환 준비 마감: 2026-05-01 (필수 과제 샷건+반동 합산 마감, 팀플 시작일과 동일) 기반 GDD: C:\Users\goldb\Claude-Code-Game-Studios\design\gdd\game-concept.md (TPS Revision)

변경 이력

날짜변경 내용사유
2026-04-20초안 작성 (10일 일정)8번 과제 착수
2026-04-24TPS 피벗 + 7일 재조정 + Day 1 체크리스트Void GDD TPS 피벗·3일 지연 만회
2026-04-26Day 2 체크리스트 + AIPerception 옵션 2 마이그레이션시야·후각 확장 대비
2026-04-27Day 3 체크리스트 + 무게/소음 D-2·Press E D-4 검증“Noise is Currency” 1차 작동
2026-04-28Day 4 체크리스트 + 마감 05-01 연장 + 샷건/반동 우선순위 + 팀플 스캐폴딩 인계 항목필수 과제(샷건+반동) 합산 + 팀플 05-01 시작
2026-04-28§14 Day 4 코드 구현 결과 + 리뷰 + 에디터 작업 추가A~E 블록 풀 C++ 코드 블록 + 6관점 리뷰 + 에디터 체크리스트

1. 개요

항목내용
프로젝트Void — 3인칭 TPS 좀비 서바이벌 익스트랙션 (개인 프로젝트)
위치 부여Void Tier 0 프로토타입 + 8번 과제 제출물
카메라TPS 숄더뷰 (Over-the-Shoulder, 오른쪽 어깨 기본) — Void 본편 TPS 피벗(2026-04-24)과 동일
플레이어Character 기반 (강의 챕터 2-1~2-4, 7번 드론 배제)
팀플 관계시점은 동일(TPS)하나 게임 정체성은 분리 — 팀플은 웨이브 로그라이트, Void는 익스트랙션 서바이벌. 팀플 05/01 별개 레포에서 진행. 캐릭터/무기 에셋 분리(8번=Manny, 팀플=Sarah)로 표절 의혹 방지
제출 레포NBC_JangSik_Assignment8 (GitHub). 로컬 폴더/프로젝트명은 VoidUnreal 유지, 클래스 접두사 VOID 유지
작업 베이스기존 D:\Unreal\VoidUnreal 그대로 사용 (36개 C++ 파일 있음). 복사 없이 git init

2. Void ↔ 8번 과제 매핑

8번 과제 “3단계 웨이브”를 Void Tier 0 “건물 3층 진입 익스트랙션”으로 재해석:

WaveVoid 해석공간시간핵심 메카닉 학습
Wave 1: 1층 (로비)튜토리얼 겸 초반 파밍, 좀비 소수·느림파괴된 현관·카운터·식료품3분이동·소음 게이지·Press E 픽업·기본 근접
Wave 2: 2층 (거주/사무)중난이도 파밍, 좀비 증가복도·방·사무실4분원거리 사격·무게 시스템·숄더 피킹
Wave 3: 3층 (옥상 주차장) + 탈출부품 3개 수집 → 차량 장착 → 시동 탈출옥상 주차장4~5분ADS 조준·차량 수리·호드 대응

Wave = 층으로 해석한 이유: Void 본편의 “건물 내부 튜토리얼 → 외부 오픈 맵” 온보딩 커브와 일치. 추상적 시간 압박 대신 공간 깊이 압박(올라갈수록 좀비·루팅 가치 증가, 소음은 아래층으로 전파)으로 Pillar 4종 모두와 맞물림:

  • Pillar 1 (Noise is Currency): 상층 소음이 아래층 좀비를 끌어올림
  • Pillar 2 (No Safe Haven): 3층에서 수리 중 아래층 좀비가 계단으로 올라올 수 있음
  • Pillar 3 (Tactical Over Twitch): 계단·복도·문 진입 = TPS 숄더 피킹 튜토리얼
  • Pillar 4 (The World Remembers): 지나온 층 루팅은 고갈, 전진만 가능

탈출 구조: 부품은 3층에서 수집, 차량도 옥상에 위치 — 올라가는 게 곧 탈출의 조건. (선택: 개발 시간 여유 시 “3층 부품 수집 → 1층 출구 탈출” 순환 구조로 확장 — Pillar 4 극대화)

점수 = 파밍 아이템 가치 + 층별 클리어 보너스. 탈출 성공 시만 점수 인정 (Void의 익스트랙션 철학).

TPS 피벗으로 추가되는 긴장감: 좁은 시야 → 청각 의존 ↑, ADS(조준) 시 이동 속도 감소 → 교전 전 위치 선정 중요성 ↑. 실내 복도·계단에서 숄더 스위치와 문 진입 판단이 핵심.

용어 유지: 부트캠프 과제 평가는 “3단계 웨이브” 용어 기준이므로 코드/문서는 Wave 1/2/3 유지, UI 배너만 “1층 진입 / 2층 진입 / 옥상 탈출”로 표시해 공간 진행감 강조.


3. 핵심 시스템: 타르코프식 무게 시스템

Void 필러 “Noise is Currency”를 구현하는 메카닉. 디버프(과적)와 Wave 3의 핵심 긴장감을 동시에 담당.

아이템 무게 테이블

카테고리예시무게(kg)비고
일반 파밍통조림, 약, 탄약 소량0.3 ~ 1.5 
중량 파밍의료 키트, 탄약 박스3 ~ 5점수 높음
차량 부품: 배터리Wave 38 kg이동속도 중간 페널티
차량 부품: 연료통Wave 312 kg이동속도 대 페널티
차량 부품: 점화 플러그Wave 32 kg페널티 미미

무게 → 효과 공식

1
2
3
4
5
6
TotalWeight (kg) = Σ(아이템 무게)
WeightRatio = TotalWeight / MaxCarry (예: MaxCarry = 30kg)

이동속도 배수 = 1.0 - (WeightRatio × 0.6)   // 최대 60% 감소
소음 배수     = 1.0 + (WeightRatio × 1.2)   // 최대 120% 증가
스태미나 소모 = 1.0 + (WeightRatio × 1.0)   // 최대 2배
  • 배터리+연료통 동시 소지 = 20kg / 30 = 0.67 → 이속 -40%, 소음 +80% (호드 유도)
  • 모든 부품 22kg = 한 번에 못 옮김 → 전략적 동선 필요
  • 스태미나 소모 증가 → 달리기 못 함 → 걸어야 함 → 소음은 줄지만 속도 손해

HUD 표시

1
2
3
4
┌─────────────────────────┐
│ ⚖ 15.2 / 30 kg  [■■■□□] │  ← 무게 게이지
│ 🔊 Noise: ▓▓▓▓▓░░░░     │  ← 소음 게이지 (무게와 연동)
└─────────────────────────┘

4. 주제별 구현 계획 (챕터 3-1 → 4-4)

챕터 3-1 — 인터페이스 기반 아이템 클래스

  • IItemInterface — 파밍 아이템 공통 (픽업, 드롭, 점수 반환, 무게 반환)
  • 파생 클래스: APickup_Food, APickup_Medkit, APickup_Ammo, APickup_VehiclePart
  • UItemDataAsset — Name, Icon, Weight, ScoreValue, Rarity

챕터 3-2 — 충돌 이벤트 아이템

  • OnComponentBeginOverlap — 플레이어가 아이템 근처 접근 시 “Press E” 프롬프트 활성
  • 자동 수집 아님 (Void 철학: “열까 말까 판단”). E 키로 명시적 획득
  • 획득 시 인벤토리 무게 추가, 실패 시 무게 초과 알림

챕터 3-3 — 아이템 스폰 및 레벨 데이터 관리

  • ASpawnVolume — 각 층의 파밍 포인트 · 좀비 스폰 포인트 (층별 배치)
  • FVOIDWaveData (DataTable) — Wave = Floor로 해석
    1
    2
    3
    4
    
    FloorIndex | TimeLimit | ZombieCount | ItemSpawnCount | FloorTheme | GoalType
    1 (Lobby)  | 180       | 3           | 8              | Safe       | Reach_Stair
    2 (Office) | 240       | 8           | 12             | Mid        | Reach_Stair
    3 (Rooftop)| 300       | 15 (+호드) | 3 (부품)       | Escape     | CarRepair
    
  • 층 진입 트리거: 계단 끝 UBoxComponent에 플레이어 Overlap 시 GameMode->AdvanceWave() 호출
  • 계단 NavLink (UNavLinkProxy)로 좀비 AI가 계단 이동 가능하게 연결
  • 소음 전파 규칙: FloorIndex N에서 발생한 소음이 N-1 층 좀비 어그로로 감쇠 계수 0.3~0.5 적용되어 전파 (UVOIDNoiseComponent::BroadcastNoise에서 FloorIndex 비교)
  • FMath::RandRange로 각 층 내 파밍 포인트 위치 변동 (재플레이성)

챕터 3-4 — 체력·점수·소음 관리 시스템

  • UHealthComponent — 플레이어·좀비 공통
  • UInventoryComponent — TotalWeight, MaxCarry, AddItem, RemoveItem, CanCarry
  • UNoiseComponent — CurrentNoise, 행동별 소음 생성(걷기/달리기/사격/픽업), 감쇠
  • AMyGameState — CurrentScore, CurrentWaveIndex, RemainingTime, DebuffList
    • OnScoreChanged, OnWaveChanged, OnDebuffUpdated 델리게이트

챕터 3-5 — 게임 루프 설계

  • AVOIDGameMode — StartWave → WaveInProgress → WaveEnd → AdvanceWave 상태 머신 (내부적으로 FloorIndex 증가, UI는 “층” 표시)
  • FTimerHandle 층별 타이머 + 부품 수집 카운터 (Floor 3)
  • 종료 조건:
    • Floor 1·2: 계단 끝 Trigger Overlap → 다음 층 진입 (제한시간 만료 시 게임오버)
    • Floor 3: 차량 시동 성공 (탈출) OR 제한시간 만료 (실패)
  • 배너 메시지 (UE_LOG + AddOnScreenDebugMessage):
    • Floor 1 시작: "1층 진입 — 로비 파밍"
    • Floor 2 시작: "2층 진입 — 거주/사무 구역"
    • Floor 3 시작: "옥상 진입 — 차량 수리 시작"

챕터 4-1 — UI 위젯 설계·실시간 데이터 연동

UHUDWidget + WBP_HUD (BindWidget 전부 C++ 바인딩)

1
2
3
4
5
좌상단: 체력 바, 스태미나 바
우상단: 점수, Wave 번호, 남은 시간
좌하단: 무게 게이지, 소음 게이지  ← 타르코프 영감
우하단: 활성 디버프 아이콘 리스트 (출혈/골절/과적)
중앙: "Press E" 프롬프트 (3D 위젯)

챕터 4-2 — 게임 흐름에 맞춘 메뉴 UI

  • WBP_MainMenu — 시작 / 종료
  • WBP_GameOver — 재시작 / 메인 메뉴 (탈출 실패 시)
  • WBP_ExtractSuccess — 탈출 성공 화면 + 파밍 아이템 리스트 + 최종 점수
  • 입력 모드 전환: 플레이 GameOnly ↔ 메뉴 UIOnly

챕터 4-3 — UI 애니메이션 + 3D 위젯 (도전)

2D 애니메이션

  • 메뉴 Fade In 0.5초
  • 웨이브 시작 Banner 슬라이드 인/아웃
  • 체력 30% 이하 시 화면 테두리 적색 비네트 펄스
  • 무게 90% 이상 시 무게 게이지 노란색 깜빡임

3D 위젯

  • 파밍 아이템 근처 “Press E” 프롬프트 (Screen Space)
  • Wave 3 차량 부품 위치 인디케이터 (World Space, 거리 표시) ← 3D 위젯 도전 핵심
  • 좀비 인식 범위 원형 링 (WorldSpace Decal + 디버그용)

챕터 4-4 — 파티클·사운드

  • SFX: 발소리(걷기/달리기/표면별 차등), 총격, 근접 타격, 좀비 신음, 계단 발소리, 차량 시동
  • VFX: 머즐 플래시, 피격 핏, 소음 웨이브 시각화(바닥 링), 층 전환 시 화면 Fade
  • BGM: 1층 앰비언트(조용), 2층 불안(복도 긴장), 3층 긴박(호드 압박. 탈출 성공 시 희망)

5. 도전 기능 매핑

도전 항목Void 버전
디버프 시스템출혈(체력 -1/s) / 골절(이속 -50%) / 과적(무게 기반 이속·소음·스태미나 동적 변경)
층별 환경 변화Floor 1 (로비): 안전 파밍 학습 / Floor 2 (거주/사무): 복잡한 실내 탐색·원거리 교전 도입 / Floor 3 (옥상): 차량 수리 + 호드 + 1·2층 누적 소음이 계단 타고 유입되어 추격 긴장감. 층 상승 자체가 “Point of No Return”
UI 애니메이션비네트 펄스, 무게 경고 깜빡임, 층 진입 배너 슬라이드 (“1층 진입 → 2층 진입 → 옥상 탈출”), 메뉴 Fade
3D 위젯“Press E” 프롬프트, Floor 3 차량 부품 위치 인디케이터 (옥상 주차장 내 3곳), 좀비 인식 링, 계단 입구 “다음 층” 표시

디버프 3종 (2개 최소 요구 충족 + 1개 보너스)

  • 출혈 (좀비 공격 시 확률 부여) — 체력 축
  • 골절 (높은 곳 낙하 또는 좀비 강타 시) — 이동 축
  • 과적 (아이템 무게 기반 동적 계산) — 소음·전략 축 (Void 필러 직결)

6. 7일 재조정 일정 (2026-04-24 기준, TPS 피벗 반영)

원 계획 (4/21~4/30, 10일) 대비 3일 지연 → 오늘(4/24)을 Day 1로 재시작. 기존 D:\Unreal\VoidUnreal에 C++ 36개 파일이 이미 있어(4/20 작업분) 뼈대 구축 일정을 압축 가능.

실제 진행 상태 (4/24 시작 시점)

  • ✅ C++ 36개 파일 구조 완성 (Core/Characters/AI/Components/Items/Waves/UI)
  • ✅ Void GDD TPS 피벗 완료 (2026-04-24)
  • AVOIDPlayerCharacter TPS 카메라 리그·Move·Look 구현 완료 (Claude 보조)
  • ⬜ Git 초기화 + GitHub push
  • ⬜ IA 5개 에셋 + BP_VOIDPlayerCharacter + Mesh 세팅
  • ⬜ 나머지 모든 기능

날짜별 일정

날짜작업산출물
4/24 (목, Day 1) ← 오늘Git init + GitHub push / IA 5개 에셋 (IMC_Default, IA_Move/Look/Interact/Fire) / BP_VOIDPlayerCharacter 자식 BP + UE5 Manny 메시 바인딩 / GameMode Override / TPS WASD 걷기 + 마우스 카메라 PIE 검증TPS 기본 이동 동작
4/25 (금, Day 2)UHealthComponent·UNoiseComponent 검증 / 좀비 AI (BT/BB) / 근접 공격 + 원거리 사격 (LineTrace) / NoiseComponent → AIPerception 연결좀비 탐지·추적·공격 + 전투 가능
4/26 (토, Day 3)건물 3층 레벨 제작 (1층 로비·2층 거주/사무·3층 옥상) + FAB 실내 키트 배치 + NavMeshBoundsVolume 층별 배치 + 계단 NavLink / ASpawnVolume 층별 배치 + DT_VOIDWaveData 3행 / AVOIDGameMode 층 상태 머신 (Floor 1·2) + 계단 끝 BoxTrigger로 층 전환 / UInventoryComponent 무게 기반 + 파밍 아이템 3~4종 + Press E 픽업1층→2층 진입 + 파밍 루프
4/27 (일, Day 4)Floor 3 옥상 차량 수리 (부품 3개 + 무게 페널티 + 시동 탈출) + 호드 스폰 + 누적 소음 아래층 전파 (FloorIndex 비교 + 감쇠 0.3~0.5) / ADS 조준 시스템 (FOV 축소·SpringArm Length 보간·이동속도 감소) / 차량 탑승 카메라 전환3층 탈출 완주 + TPS 조준 전투
4/28 (월, Day 5)WBP_VOIDHUD (체력·무게·소음·웨이브·시간·디버프) / 메인·게임오버·탈출 성공 메뉴 / 입력 모드 GameOnlyUIOnly 전환UI 기능 완성
4/29 (화, Day 6)도전: 디버프 3종 HUD 연동 + UI 애니메이션 (비네트 펄스·무게 경고 깜빡임·웨이브 배너 슬라이드·Fade) / 3D 위젯 (부품 인디케이터·Press E 프롬프트) / 숄더 스위치 (시간 여유 시) / 파티클·SFX 최소 배치도전 1·2·3·4 완료
4/30 (수, Day 7)밸런싱 (웨이브 난이도·소음 수치·무게 임계값) / 시연 영상 녹화·편집 / README 완성 / 태그 + 제출제출

Buffer & 리스크

  • 4/24 Day 1 블로커 리스크 최대 — 여기서 막히면 전체 일정 흔들림. 저녁까지 PIE 검증 필수
  • 4/27 ADS 조준은 시점 보정용 중요 기능 — 시간 부족 시 Wave 3 차량 수리를 우선하고 ADS는 Day 6로 이월
  • 4/29 숄더 스위치는 Stretch — 본질 기능 다 되면 추가, 아니면 버림
  • 4/30은 순수 제출 버퍼 — 새 기능 금지, 밸런싱/영상/README만

하루별 완료 기준 (내일로 넘어가도 되는 최소치)

| Day | 내일로 넘어갈 수 있는 최소 기준 | |—|—| | Day 1 (4/24) | PIE에서 TPS WASD 걷기 + 마우스 카메라 동작 + Git push 완료 | | Day 2 (4/25) | 좀비 1마리 플레이어 추적 + 총/근접으로 처치 가능 | | Day 3 (4/26) | 1층→2층 계단 Trigger 진입 자동 전환 + 아이템 1개 이상 픽업으로 무게 게이지 변화 | | Day 4 (4/27) | 옥상(Floor 3)에서 부품 3개 픽업 + 차량 탑승 → 탈출 성공 화면. 아래층 소음 전파로 호드 유입 확인 | | Day 5 (4/28) | HUD 전 게이지 실시간 바인딩 + 3개 메뉴 동작 | | Day 6 (4/29) | 디버프 3종 HUD 표시 + 3D 위젯 부품 인디케이터 1개 이상 | | Day 7 (4/30) | 10분 시연 영상 + README + GitHub Release 태그 |


7. 평가 기준 대응 체크리스트

완성도 (40점)

  • 3단계 웨이브 (진입·심층·탈출), 스폰·타이머 초기화
  • 층 진입 자동 전환 (계단 Trigger Overlap → 다음 Wave/Floor)
  • HUD: 체력·점수·시간 + 무게·소음 한 화면 실시간
  • 메뉴: 시작·재시작·탈출성공·게임오버 버튼 동작
  • 층별 난이도 증가 (좀비 수·밀도·호드, 소음 전파, 시간 압박)
  • UI 통일성 (어두운 톤 + 중요 요소 하이라이트)
  • 입력 모드 자연스러운 전환
  • 독창적 디자인: 타르코프식 무게 HUD, TPS 숄더뷰 + ADS 조준, 소음 경제, 건물 3층 수직 진행

이해도 (30점)

  • 웨이브(=층) 상태 머신 논리적 설계 + 계단 NavLink로 좀비 AI 층 이동
  • UMG BindWidget + 델리게이트 정확한 사용
  • Component 설계로 재사용성 확보 (Health, Inventory, Noise, Debuff)
  • 과적 시스템으로 확장 가능한 디버프 구조
  • Enhanced Input + TPS 카메라 리그 올바른 구성 (bUseControllerRotationYaw, SocketOffset)

우수성 (30점)

  • 안정적 구동
  • Floor 3 옥상 차량 수리 + 호드 이벤트 + 아래층 소음 전파 (도전)
  • UI 비네트·슬라이드 애니메이션 (도전)
  • 디버프 3종 중첩·지속시간 정상 (도전)
  • 3D 위젯: 부품 인디케이터 (도전)
  • ADS 조준 시 FOV·이동속도·정확도 트레이드오프 (독창성 + TPS 완성도)

8. Void 프로젝트와의 관계

8번 과제 종료 후 Void로 발전 경로

현재 (8번 과제)Tier 0 추가 예정Tier 1 MVP 추가
TPS 숄더뷰 기초숄더 스위치·ADS 깊이 튜닝자유 탐험 모드
건물 3층 (1→2→옥상)층수 확장 + 건물 내부 정교화 + 외부 노출도심 1구역 (3~5 건물, 외부 오픈)
단일 건물건물 여러 채 순회익스트랙션 맵 여러 개
간단 인벤토리 UI슬롯/드래그 UMG장비 장착 시스템
부품 수집 탈출여러 탈출 포인트·방향퀘스트 시스템
-차량 은신처 토대이동형 차량 은신처

8번 과제 코드는 Void 본편 레포(D:\Unreal\VoidUnreal)와 동일 작업 디렉토리에서 지속 확장. GitHub 제출 레포 NBC_JangSik_Assignment8는 과제 제출 시점 스냅샷 역할. 이후 본편 개발은 별도 브랜치·태그로 관리.

팀프로젝트(Ch3)와의 관계 — TPS 피벗 후 갱신

  • 시점은 동일 (TPS) — 본인 역할 C(전투/히트판정/히트스톱/처형)에서 배우는 TPS 타격감 자산이 Void에도 이식 가능
  • 장르는 분리 — 팀플 = 웨이브 로그라이트 (압도 판타지) / Void = 익스트랙션 서바이벌 (Noise 필러)
  • 에셋 분리 — 8번 = UE5 기본 Manny 또는 무료 마켓 캐릭터 / 팀플 = Mechanic Sarah. 표절 의혹 방지
  • 이식 예정 자산 (팀플 → Void 본편, 5/28~): 히트 판정 파이프라인, 히트스톱, 피격 플린치, 카메라 셰이크, 처형 시스템 (단 Void에서는 “소음 대가가 큰 긴박한 탈출 수단”으로 재정의)

잘라낸 기능 (7일 한계)

  • ❌ 인벤토리 드래그 UMG (배열 기반 자동 수집으로 대체)
  • ❌ 차량 은신처 (Wave 3 탈출 차량만)
  • ❌ 건물 내부 정교한 레벨 (오픈 아레나 + 파밍 포인트)
  • ❌ 멀티플레이어
  • ❌ 퀘스트 시스템
  • ❌ 파쿠르 (Void GDD에는 있으나 Stretch)

9. 오늘(4/24, Day 1) 작업 블록 — 체크리스트

아래 순서대로 진행. 블로커 발생 시 다음 단계로 넘어가지 말 것. 저녁까지 PIE 검증 완료가 목표.

A. 리포지토리 기반 구축 (예상 30분)

  • D:/Unreal/VoidUnreal/.gitignore 생성 (UE5 표준: Binaries/Intermediate/DerivedDataCache/Saved/Build/*.sln/.idea 등 제외)
  • D:/Unreal/VoidUnreal/README.md 초안 작성 (“Void Tier 0 프로토타입” 명시)
  • cd D:/Unreal/VoidUnreal && git init -b main
  • .gitignore·README 먼저 커밋 → Source/Content/Config/VoidUnreal.uproject 커밋
  • GitHub에 NBC_JangSik_Assignment8 (Private) 생성 + git push -u origin main

B. 빌드 & PlayerCharacter 확인 (예상 20분)

  • VoidUnreal.uproject 우클릭 → Generate Visual Studio project files
  • Development Editor로 빌드 (방금 수정한 TPS 카메라 코드 컴파일 확인)
  • 에디터 실행 — 에디터에서 빌드 에러 없이 뜨는지 확인

C. Enhanced Input 에셋 생성 (예상 30분)

  • Content/Input/ 폴더 생성
  • IA_Move (Axis2D) / IA_Look (Axis2D) / IA_Interact (Bool) / IA_Fire (Bool) 4개 에셋 생성
  • IMC_Default 생성 후 4개 IA 매핑:
    • IA_Move: W (기본) / S (Negate) / A (Swizzle YXZ + Negate) / D (Swizzle YXZ)
    • IA_Look: Mouse XY 2D-Axis + Negate Y
    • IA_Interact: E
    • IA_Fire: Left Mouse Button

D. BP + GameMode + Level (예상 30분)

  • Content/Blueprints/ 폴더 생성
  • BP_VOIDPlayerCharacter (AVOIDPlayerCharacter 자식) 생성
    • Mesh: UE5 기본 Manny 스켈레탈 메시 + 애니 BP 바인딩 (ABP_Manny)
    • Class Defaults → Input: IMC_Default / MoveAction / LookAction / InteractAction / FireAction 5개 할당
  • BP_VOIDGameMode (AVOIDGameMode 자식) 생성
    • Default Pawn Class: BP_VOIDPlayerCharacter
  • 테스트 레벨 생성 (빈 레벨 + 바닥 Plane + 조명)
    • World Settings → Game Mode Override: BP_VOIDGameMode

E. PIE 검증 (예상 10분)

  • WASD로 캐릭터 이동 (대각선 포함)
  • 마우스로 카메라 Yaw/Pitch 회전 (Y 반전 적용되어 “위로 마우스 = 카메라 위”)
  • 오른쪽 어깨 카메라(SocketOffset 50,50), ArmLength 300 유지되는지
  • 캐릭터가 카메라 방향 따라 함께 회전 (bUseControllerRotationYaw=true)
  • E / LMB 눌러도 아무 반응 없음 (정상 — 로직은 Day 2에 구현)

F. Day 1 완료 커밋 (예상 5분)

  • git add .git commit -m "Day 1: TPS PlayerCharacter + Enhanced Input + Manny bound"
  • git push

블로커 대응

  • 빌드 에러 발생 시: VoidUnreal.Build.cs에 EnhancedInput 있는지 확인 (이미 있음). 헤더 include 누락 의심 시 VOIDPlayerCharacter.cpp 상단 include 전체 재확인
  • Manny 메시 없음: Content → Add Feature → Third Person 콘텐츠 추가 후 Mannequins 폴더 생성
  • 마우스 Y 반대로 돎: IMC에서 IA_Look의 Negate 모디파이어 체크 박스에서 Y만 true, X는 false 확인
  • Swizzle 설정 막힘: A/D 키에서 Swizzle Input Axis Values → Order 드롭다운 YXZ 확인

참고 링크


10. 오늘(4/26, Day 2) 작업 블록 — 체크리스트

Day 1을 4/26 토요일에 완료 (2일 지연). 오늘 저녁~4/27까지 Day 2+3 압축 진행. 헤더 36개는 갖춰져 있음 — 본 단계는 cpp 구현 + BT/BB 에셋 + BP 인스턴스 + PIE 검증.

Day 2 완료 기준 (구현계획 233줄)

좀비 1마리가 플레이어를 추적하고, 플레이어가 총·근접으로 처치 가능

A. C++ 컴포넌트 구현 검증 (예상 20분)

  • VOIDHealthComponent.cppApplyDamageOnHealthChanged.Broadcast(), IsDead()OnDeath.Broadcast() 동작 확인
  • VOIDNoiseComponent.cppEmitNoise()CurrentNoise += SourceValue * WeightMultiplier, Tick에서 감쇠, BroadcastNoise(Radius)로 좀비 탐색
  • VOIDBaseCharacter.cppApplyDamage()HealthComponent->ApplyDamage() 위임 확인
  • 빌드 (Development Editor) 통과

B. PlayerCharacter::Fire 구현 — LineTrace 사격 (예상 30분)

  • VOIDPlayerCharacter.cpp::Fire() 함수 구현 — ECC_Weapon 커스텀 채널 사용
  • 머즐 위치는 일단 FollowCamera->GetComponentLocation() 사용 (정확한 총구 위치는 Day 6에 정리)
  • SetupPlayerInputComponent에서 FireAction 바인딩 확인
  • DefaultEngine.ini에 Trace Channel Weapon 추가 (Default Response = Block)
  • VoidUnreal.h 모듈 헤더에 별칭 매크로 정의: #define ECC_Weapon ECC_GameTraceChannel3
1
2
// VoidUnreal.h (모듈 헤더) — 채널 별칭
#define ECC_Weapon ECC_GameTraceChannel3
1
2
; DefaultEngine.ini — 채널 등록
+DefaultChannelResponses=(Channel=ECC_GameTraceChannel3,DefaultResponse=ECR_Block,bTraceType=True,bStaticObject=False,Name="Weapon")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// VOIDPlayerCharacter.cpp 상단 include 추가
#include "VoidUnreal.h"  // ECC_Weapon 별칭
#include "Camera/CameraComponent.h"
#include "Components/VOIDNoiseComponent.h"
#include "Characters/VOIDBaseCharacter.h"
#include "DrawDebugHelpers.h"

void AVOIDPlayerCharacter::Fire(const FInputActionValue& Value)
{
    // 1. 트레이스 시작점·끝점 계산 (카메라 기준)
    const FVector Start = FollowCamera->GetComponentLocation();
    const FVector ForwardVector = FollowCamera->GetForwardVector();
    const FVector End = Start + (ForwardVector * 5000.f);  // 50m 사거리

    // 2. 충돌 쿼리 파라미터 — 자기 자신 무시
    FCollisionQueryParams Params;
    Params.AddIgnoredActor(this);
    Params.bTraceComplex = false;

    // 3. LineTrace 실행 — ECC_Weapon 커스텀 채널 (Visibility와 분리)
    FHitResult Hit;
    bool bHit = GetWorld()->LineTraceSingleByChannel(
        Hit, Start, End, ECC_Weapon, Params);

    // 4. 피격 처리
    if (bHit)
    {
        if (auto* Target = Cast<AVOIDBaseCharacter>(Hit.GetActor()))
        {
            Target->ApplyDamage(25.f);
            UE_LOG(LogTemp, Warning, TEXT("Hit %s for 25 damage"), *Target->GetName());
        }
    }

    // 5. 소음 발생 (Void 핵심 메카닉)
    if (NoiseComponent)
        NoiseComponent->EmitNoise(EVOIDNoiseSource::Gunshot);

    // 6. 디버그 시각화
    const FVector DebugEnd = bHit ? Hit.ImpactPoint : End;
    DrawDebugLine(GetWorld(), Start, DebugEnd, FColor::Red, false, 2.f, 0, 1.f);
    if (bHit)
        DrawDebugSphere(GetWorld(), Hit.ImpactPoint, 8.f, 8, FColor::Yellow, false, 2.f);
}

Weapon 채널 채택 이유 (옵션 B 정석)

 ECC_Visibility (이전)ECC_Weapon (변경) ✅
의미시야 차단 / 조준선 / 카메라 등 다목적사격 전용
좀비 Profile 영향Pawn 프리셋 직접 수정 (Visibility=Block)Pawn 그대로 + Weapon만 Block
다른 트레이스와 충돌AI 시야와 사격이 같은 채널 → 의도 불명완전 분리
차량 부품·문 등 사격 무시 객체Visibility=Ignore 시 시야도 가림Weapon만 Ignore 가능
채널 ID엔진 내장ECC_GameTraceChannel3 (DefaultEngine.ini 121줄)
1
2
3
// SetupPlayerInputComponent 안에 추가
EnhancedInputComp->BindAction(FireAction, ETriggerEvent::Started,
    this, &AVOIDPlayerCharacter::Fire);

Fire 함수별 설명

함수역할핵심 인자
FollowCamera->GetComponentLocation()카메라 월드 좌표 (총구 대용)-
FollowCamera->GetForwardVector()카메라 방향 단위벡터-
LineTraceSingleByChannel직선 충돌 검사Start, End, Channel, Params
ECC_Visibility충돌 채널 — 시각 차폐물(벽/캐릭터)enum
FCollisionQueryParams::AddIgnoredActor트레이스 무시 액터 (자기 캡슐 제외 필수)this
Hit.GetActor()충돌한 액터 포인터 (없으면 nullptr)-
Cast<T>()안전 다운캐스팅 (실패 시 nullptr)Hit.GetActor()
Target->ApplyDamage(25.f)BaseCharacter 함수 → HealthComponent 위임Amount
NoiseComponent->EmitNoise(...)소음 발생 + 좀비에게 브로드캐스트Source 종류
DrawDebugLinePIE 디버그 선World, Start, End, Color, Persistent, Duration
DrawDebugSphere충돌 지점 디버그 구World, Center, Radius, Segments, Color, Duration

C. ZombieCharacter 근접 공격 cpp (예상 30분)

  • VOIDZombieCharacter.hAttackPlayer 선언 + bCanAttack/AttackCooldownHandle 멤버 추가
  • VOIDZombieCharacter.cpp::AttackPlayer() 구현 (거리·쿨다운·데미지)
  • ReactToNoise() 구현 — 소음 위치를 Blackboard LastNoiseLocation 키에 저장

C-1. AttackPlayer 구현

1
2
3
4
5
6
7
8
9
10
11
// VOIDZombieCharacter.h
public:
    UFUNCTION(BlueprintCallable, Category="Combat")
    void AttackPlayer(AActor* Target);

protected:
    bool bCanAttack = true;
    FTimerHandle AttackCooldownHandle;

    UPROPERTY(EditDefaultsOnly, Category="Combat")
    float AttackCooldown = 1.0f;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// VOIDZombieCharacter.cpp 상단 include
#include "Characters/VOIDBaseCharacter.h"
#include "TimerManager.h"

void AVOIDZombieCharacter::AttackPlayer(AActor* Target)
{
    // 1. 쿨다운 체크
    if (!bCanAttack || !Target) return;

    // 2. 거리 체크 (AttackRange = 100)
    const float Distance = FVector::Dist(GetActorLocation(), Target->GetActorLocation());
    if (Distance > AttackRange) return;

    // 3. 데미지 적용
    if (auto* TargetCharacter = Cast<AVOIDBaseCharacter>(Target))
    {
        TargetCharacter->ApplyDamage(AttackDamage);
        UE_LOG(LogTemp, Warning, TEXT("Zombie hit %s for %.1f"),
            *Target->GetName(), AttackDamage);
    }

    // 4. 쿨다운 시작
    bCanAttack = false;
    GetWorld()->GetTimerManager().SetTimer(
        AttackCooldownHandle,
        [this]() { bCanAttack = true; },
        AttackCooldown,
        false
    );
}
AttackPlayer 함수별 설명
함수역할비고
FVector::Dist(A, B)두 벡터 거리(스칼라)sqrt 포함, 빈번 호출은 DistSquared 권장
GetActorLocation()액터 월드 좌표-
Cast<AVOIDBaseCharacter>(Target)안전 다운캐스팅실패 시 nullptr
GetWorldTimerManager()월드 단위 타이머 매니저World 죽으면 자동 정리
SetTimer(Handle, Func, Time, bLoop)N초 후 함수 실행bLoop=true면 반복
람다 [this](){ ... }익명 함수, this 캡처해서 멤버 접근짧은 콜백용 편함

C-2. ReactToNoise 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// VOIDZombieCharacter.cpp 상단 include
#include "AIController.h"
#include "BehaviorTree/BlackboardComponent.h"

void AVOIDZombieCharacter::ReactToNoise(const FVector& NoiseLocation, float NoiseRadius)
{
    AAIController* AIController = Cast<AAIController>(GetController());
    if (!AIController) return;

    UBlackboardComponent* BB = AIController->GetBlackboardComponent();
    if (!BB) return;

    BB->SetValueAsVector(TEXT("LastNoiseLocation"), NoiseLocation);

    UE_LOG(LogTemp, Log, TEXT("Zombie %s reacting to noise at %s"),
        *GetName(), *NoiseLocation.ToString());
}
ReactToNoise 함수별 설명
함수역할비고
GetController()폰을 빙의 중인 컨트롤러좀비=AIController, 플레이어=PlayerController
Cast<AAIController>AI 전용 컨트롤러 다운캐스팅플레이어 빙의면 nullptr
GetBlackboardComponent()AIController의 BB 인스턴스RunBehaviorTree() 후에야 존재
SetValueAsVector(Key, Value)BB 키에 Vector 저장키 이름은 BB_Zombie 에셋과 일치 필수
다른 Set 함수들SetValueAsBool / SetValueAsObject / SetValueAsFloat키 타입에 맞춰 호출
Blackboard 키 매핑 (BB_Zombie ↔ C++)
BB 키 (에셋)타입C++ 호출
TargetActorObject: ActorSetValueAsObject(TEXT("TargetActor"), Actor)
TargetLocationVectorSetValueAsVector(TEXT("TargetLocation"), Loc)
bHasTargetBoolSetValueAsBool(TEXT("bHasTarget"), true)
LastNoiseLocationVectorSetValueAsVector(TEXT("LastNoiseLocation"), Loc)
호출 흐름 (사격 → 좀비 추적)
1
2
3
4
5
6
7
8
플레이어 사격 (LMB)
  └─ AVOIDPlayerCharacter::Fire()
      └─ NoiseComponent->EmitNoise(Gunshot)
          └─ NoiseComponent::BroadcastNoise(Radius)
              └─ GetAllActorsOfClass(좀비) → 각 좀비:
                  └─ Zombie->ReactToNoise(NoiseLoc, Radius)
                      └─ Blackboard["LastNoiseLocation"] = NoiseLoc
                          └─ BT_Zombie가 MoveTo(LastNoiseLocation) 실행
핵심 함수 정리
구현핵심 함수가장 헷갈릴 부분
FireLineTraceSingleByChannel채널(ECC_Visibility)과 ignore actor
AttackPlayerFTimerHandle + 람다핸들을 헤더 멤버로 둘 것
ReactToNoiseBlackboardComponent::SetValueAsVector키 이름 문자열 일치 (에셋 ↔ 코드)

D. Behavior Tree / Blackboard 에셋 (예상 40분)

  • Content/AI/ 폴더 생성
  • BB_Zombie (Blackboard) 생성 — 키 4개:
    • TargetActor (Object: Actor)
    • TargetLocation (Vector)
    • bHasTarget (Bool)
    • LastNoiseLocation (Vector)
  • BT_Zombie (Behavior Tree) 생성 — 트리 구조:
    1
    2
    3
    4
    5
    6
    7
    8
    
    Selector
    ├─ Sequence: "공격"
    │   ├─ Decorator: bHasTarget == true
    │   ├─ MoveTo: TargetActor (Acceptable Radius 90)
    │   └─ Task: BTTask_ZombieAttack (커스텀)
    └─ Sequence: "소음 추적"
        ├─ Decorator: LastNoiseLocation IsSet
        └─ MoveTo: LastNoiseLocation
    
  • BTTask_ZombieAttack (BP로 빠르게) — OwnerPawn->AttackPlayer() 호출 → Finish Execute (Success=true)
  • BP_VOIDZombieAIController 생성 → BehaviorTreeAsset / BlackboardAsset 할당
  • BP_VOIDZombieCharacter 생성 (AVOIDZombieCharacter 자식):
    • Mesh: UE5 기본 Manny (색상 다르게) 또는 Quinn
    • AI Controller Class: BP_VOIDZombieAIController
    • Auto Possess AI: Placed in World or Spawned

E. NoiseComponent → 좀비 청각 연결 (예상 40분 — 옵션 2 적용)

최종 채택: 옵션 2 (AIPerception + AISense_Hearing) — 3일 지연분 만회 후 정석 패턴으로 마이그레이션 (2026-04-26).

E-1. 마이그레이션 이유

비교옵션 1 (GetAllActorsOfClass)옵션 2 (AIPerception)
코드량적음 (15줄)많음 (3파일 수정)
성능 (좀비 100+)매 사격마다 풀 스캔내부 공간 분할 최적화
시야(Sight) 추가직접 LineTrace 구현 필요AISense_Sight 추가만
후각(Damage) 추가직접 구현AISense_Damage 추가만
노이즈 감쇠 (벽 막힘)직접 구현Loudness·MaxRange 자동
디버그 도구DrawDebug 수동Visual Logger 자동 통합
Day 4 이후 확장성✅ (시야·후각 손쉬운 추가)

E-2. 모듈 의존성 확인

Source/VoidUnreal/VoidUnreal.Build.cs에 이미 포함됨:

1
2
3
4
5
PublicDependencyModuleNames.AddRange(new[] {
    "Core", "CoreUObject", "Engine", "InputCore",
    "AIModule", "GameplayTasks", "NavigationSystem",  // ← AIPerception 필수
    "UMG", "EnhancedInput"
});

E-3. 변경 파일 5개

VOIDZombieAIController.h — AIPerception 멤버 추가
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#pragma once

#include "CoreMinimal.h"
#include "AIController.h"
#include "VOIDZombieAIController.generated.h"

class UBehaviorTree;
class UBlackboardData;
class UAIPerceptionComponent;
class UAISenseConfig_Hearing;
struct FAIStimulus;

UCLASS(Blueprintable)
class VOIDUNREAL_API AVOIDZombieAIController : public AAIController
{
    GENERATED_BODY()

public:
    AVOIDZombieAIController();

    virtual void OnPossess(APawn* InPawn) override;

protected:
    UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="AI")
    TObjectPtr<UBehaviorTree> BehaviorTreeAsset;

    UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="AI")
    TObjectPtr<UBlackboardData> BlackboardAsset;

    // 옵션 2: AIPerception 기반 청각 시스템
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="AI|Perception")
    TObjectPtr<UAIPerceptionComponent> AIPerceptionComp;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="AI|Perception")
    TObjectPtr<UAISenseConfig_Hearing> HearingConfig;

    UFUNCTION()
    void OnPerceptionUpdated(AActor* Actor, FAIStimulus Stimulus);
};
VOIDZombieAIController.cpp — 생성자에서 Perception 설정 + 콜백 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include "AI/VOIDZombieAIController.h"

#include "BehaviorTree/BehaviorTree.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "BehaviorTree/BlackboardData.h"
#include "Perception/AIPerceptionComponent.h"
#include "Perception/AISenseConfig_Hearing.h"
#include "Perception/AISense_Hearing.h"
#include "GameFramework/Pawn.h"

AVOIDZombieAIController::AVOIDZombieAIController()
{
    // AIPerception 컴포넌트 생성
    AIPerceptionComp = CreateDefaultSubobject<UAIPerceptionComponent>(TEXT("AIPerceptionComp"));

    // Hearing Sense 설정
    HearingConfig = CreateDefaultSubobject<UAISenseConfig_Hearing>(TEXT("HearingConfig"));
    HearingConfig->HearingRange = 3000.f;
    HearingConfig->DetectionByAffiliation.bDetectEnemies   = true;
    HearingConfig->DetectionByAffiliation.bDetectFriendlies = true;
    HearingConfig->DetectionByAffiliation.bDetectNeutrals  = true;

    AIPerceptionComp->ConfigureSense(*HearingConfig);
    AIPerceptionComp->SetDominantSense(HearingConfig->GetSenseImplementation());

    // 인지 이벤트 콜백 바인딩
    AIPerceptionComp->OnTargetPerceptionUpdated.AddDynamic(
        this, &AVOIDZombieAIController::OnPerceptionUpdated);
}

void AVOIDZombieAIController::OnPossess(APawn* InPawn)
{
    Super::OnPossess(InPawn);

    if (BlackboardAsset)
    {
        UBlackboardComponent* BlackboardComponent = nullptr;
        UseBlackboard(BlackboardAsset, BlackboardComponent);
    }

    if (BehaviorTreeAsset)
    {
        RunBehaviorTree(BehaviorTreeAsset);
    }
}

void AVOIDZombieAIController::OnPerceptionUpdated(AActor* Actor, FAIStimulus Stimulus)
{
    if (!Stimulus.WasSuccessfullySensed()) return;

    UBlackboardComponent* BB = GetBlackboardComponent();
    if (!BB) return;

    // 1. 청각 자극 위치를 BB에 저장 → BT의 Chase 분기 트리거
    BB->SetValueAsVector(TEXT("LastNoiseLocation"), Stimulus.StimulusLocation);

    // 2. 소음 발신자가 Pawn이고 자기 자신이 아니면 공격 대상 지정
    //    → BT의 Attack 분기 트리거 (bHasTarget Is Set 데코레이터 통과)
    if (Actor && Actor->IsA(APawn::StaticClass()) && Actor != GetPawn())
    {
        BB->SetValueAsObject(TEXT("TargetActor"), Actor);
        BB->SetValueAsBool(TEXT("bHasTarget"), true);
    }
}
VOIDNoiseComponent.cpp::BroadcastNoiseReportNoiseEvent 호출
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include "Components/VOIDNoiseComponent.h"

#include "Perception/AISense_Hearing.h"
#include "DrawDebugHelpers.h"

void UVOIDNoiseComponent::BroadcastNoise(float Radius)
{
    if (!GetOwner() || !GetWorld()) return;

    const FVector NoiseLocation = GetOwner()->GetActorLocation();

    // AIPerception 시스템에 청각 이벤트 보고
    // → AIPerceptionComponent를 가진 모든 AI가 자동 OnTargetPerceptionUpdated 수신
    UAISense_Hearing::ReportNoiseEvent(
        GetWorld(),
        NoiseLocation,
        1.0f,           // Loudness (1.0 = HearingRange 그대로)
        GetOwner(),     // Instigator (소음 발신자)
        Radius,         // Max Range
        NAME_None       // Tag (선택적 분류)
    );

#if !(UE_BUILD_SHIPPING)
    DrawDebugSphere(GetWorld(), NoiseLocation, Radius, 16, FColor::Cyan, false, 1.0f, 0, 1.5f);
#endif
}
VOIDZombieCharacter.h / .cppReactToNoise 제거 (dead code)

청각 처리는 이제 AIController가 담당하므로 ZombieCharacter의 ReactToNoise 함수는 삭제. AttackPlayer는 그대로 유지.

1
2
3
// VOIDZombieCharacter.h - public 영역에서 ReactToNoise 선언 제거

// VOIDZombieCharacter.cpp - 함수 본문 + 관련 include(AIController.h, BlackboardComponent.h) 제거
VOIDPlayerCharacter.h / .cpp — StimuliSource 컴포넌트 코드 추가

옵션 2 핵심: 플레이어가 소음 발신원으로 등록돼야 좀비 AIPerception이 “누가 냈는지” 식별 가능. BP 컴포넌트로 추가하면 인스턴스 누락 위험이 있어 C++로 보장.

1
2
3
4
5
// VOIDPlayerCharacter.h - forward declaration + UPROPERTY 추가
class UAIPerceptionStimuliSourceComponent;

UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Components")
TObjectPtr<UAIPerceptionStimuliSourceComponent> StimuliSource;
1
2
3
4
5
6
7
8
// VOIDPlayerCharacter.cpp - include 추가
#include "Perception/AIPerceptionStimuliSourceComponent.h"
#include "Perception/AISense_Hearing.h"

// 생성자 끝에 추가
StimuliSource = CreateDefaultSubobject<UAIPerceptionStimuliSourceComponent>(TEXT("StimuliSource"));
StimuliSource->bAutoRegister = true;
StimuliSource->RegisterForSense(UAISense_Hearing::StaticClass());

→ 이로써 모든 AVOIDPlayerCharacter 인스턴스가 자동으로 청각 발신원이 됨. BP 누락 사고 방지.

E-4. 함수별 설명

UAISense_Hearing::ReportNoiseEvent
파라미터의미
World월드 컨텍스트
NoiseLocation소음 발생 위치 (FVector)
Loudness소음 크기 배수 (1.0 = HearingRange 그대로, 2.0 = 2배 멀리 들림)
Instigator소음 발신 액터 (좀비 콜백에서 SourceActor로 전달됨)
MaxRange최대 전파 거리 (Hearing 컴포넌트 자체의 HearingRange와 별개)
Tag분류용 FName (예: “Gunshot”, “Footstep” — 옵션)
UAIPerceptionComponent::ConfigureSense

각 Sense(Hearing/Sight/Damage 등)를 컴포넌트에 등록. 여러 Sense를 같이 등록 가능 (Day 4에 Sight 추가 시 같은 패턴).

OnTargetPerceptionUpdated 델리게이트

FAIStimulus 구조체의 주요 필드:

  • StimulusLocation — 자극 발생 위치 (소음 났던 곳)
  • WasSuccessfullySensed() — true면 감지 시작, false면 감지 만료
  • 콜백 첫 인자 AActor* Actor — 자극 발신자 (Instigator)

E-5. 호출 흐름 (옵션 2 적용 후)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
플레이어 사격 (LMB)
  └─ AVOIDPlayerCharacter::Fire()
      └─ NoiseComponent->EmitNoise(Gunshot)
          ├─ CurrentNoise += 10 → OnNoiseChanged.Broadcast → HUD
          └─ BroadcastNoise(3000)
              └─ UAISense_Hearing::ReportNoiseEvent(World, Loc, 1.0, Player, 3000)
                  └─ [언리얼 AIPerceptionSystem이 자동 처리]
                      └─ 범위 내 모든 AIPerceptionComponent에:
                          └─ OnTargetPerceptionUpdated(Player, Stimulus) 호출
                              └─ AVOIDZombieAIController::OnPerceptionUpdated()
                                  ├─ BB["LastNoiseLocation"] = Stimulus.StimulusLocation
                                  ├─ BB["TargetActor"] = Player
                                  └─ BB["bHasTarget"] = true
                                      └─ BT_Zombie:
                                          ├─ Attack 분기: bHasTarget Is Set ✅ → MoveTo TargetActor → BTTask_ZombieAttack
                                          └─ (Attack 실패 시) Chase 분기 → MoveTo LastNoiseLocation

E-6. PIE 전 점검

항목확인
BP_VOIDZombieCharacter의 AI Controller ClassBP_VOIDZombieAIController (또는 C++ AVOIDZombieAIController)
Auto Possess AIPlaced in World or Spawned
Output LogLogPerception 카테고리 활성화 시 청각 이벤트 추적 가능
Visual LoggerWindow → Developer Tools → Visual Logger — 시간축으로 AI 인지 재생

E-7. Day 4+ 확장 경로

옵션 2 채택했으니 Sight 추가가 5분 작업:

1
2
3
4
5
6
// AVOIDZombieAIController 생성자에 추가
SightConfig = CreateDefaultSubobject<UAISenseConfig_Sight>(TEXT("SightConfig"));
SightConfig->SightRadius = 1500.f;
SightConfig->LoseSightRadius = 1800.f;
SightConfig->PeripheralVisionAngleDegrees = 90.f;
AIPerceptionComp->ConfigureSense(*SightConfig);

플레이어 쪽에는 UAIPerceptionStimuliSourceComponent 추가로 시야 감지 가능 (Day 4 ADS 작업과 함께).

F. PIE 검증 (예상 15분)

  • 테스트 레벨에 BP_VOIDZombieCharacter 1마리 배치 (NavMeshBoundsVolume 필수)
  • 조용히 좀비에게 다가가도 아무 반응 없음 → 정상 (시야 미구현)
  • 사격(LMB) → 좀비가 소음 위치로 추적 시작
  • 거리 100 이하로 접근 → 좀비가 공격 → 플레이어 체력 감소 (UE_LOG 확인)
  • 좀비를 4발 사격 (25 × 4 = 100) → 사망 → OnDeath 델리게이트 로그

G. 좀비 죽음 처리 (예상 10분 — Day 3 진입 전 필수)

G-1. 미구현 시 영향

체력 0인데도:

  • BT 계속 Tick → MoveTo·AttackPlayer 계속 발동
  • 죽은 좀비가 계속 때림 → Day 3 후반(스폰 볼륨 4~8마리) PIE 검증 시 명확히 헷갈림
  • 시각적으로 살아 있는 것과 구분 안 됨

G-2. 해결 방향: AIController Tick 폴링

HealthComponent.OnDeath 델리게이트 구독 대신 AIController Tick에서 IsDead() 폴링 채택:

  • 코드량 적음 (5줄)
  • 다음 Tick에 처리 (16ms 지연 무시 가능)
  • bCanEverTick=false로 한 번만 처리하고 종료 → 매 프레임 비용 0

G-3. 코드 변경 (1개 파일)

1
2
3
4
5
// VOIDZombieAIController.h
public:
    AVOIDZombieAIController();
    virtual void OnPossess(APawn* InPawn) override;
    virtual void Tick(float DeltaSeconds) override;  // ← 추가
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// VOIDZombieAIController.cpp 상단 include 추가
#include "Characters/VOIDZombieCharacter.h"
#include "Components/VOIDHealthComponent.h"
#include "BrainComponent.h"
#include "GameFramework/CharacterMovementComponent.h"

AVOIDZombieAIController::AVOIDZombieAIController()
{
    PrimaryActorTick.bCanEverTick = true;  // ← 추가

    // ... 기존 AIPerception 설정 그대로 ...
}

void AVOIDZombieAIController::Tick(float DeltaSeconds)
{
    Super::Tick(DeltaSeconds);

    auto* Zombie = Cast<AVOIDZombieCharacter>(GetPawn());
    if (!Zombie || !Zombie->IsDead()) return;

    // 1. BT 정지 — MoveTo·BTTask_ZombieAttack 즉시 중단
    if (UBrainComponent* Brain = GetBrainComponent())
        Brain->StopLogic(TEXT("Dead"));

    // 2. 이동 중지 — 죽은 후 미끄러짐 방지
    if (auto* Movement = Zombie->GetCharacterMovement())
        Movement->DisableMovement();

    // 3. 5초 후 자동 제거 (시연 영상 정리)
    Zombie->SetLifeSpan(5.0f);

    // 4. Tick 자체 중단 — 한 번만 처리
    PrimaryActorTick.bCanEverTick = false;
}

G-4. 함수별 설명

함수역할
BrainComponent::StopLogic(Reason)BT 전체 중단 — 디버그용 사유 문자열 받음
CharacterMovementComponent::DisableMovement()이동 모드 None — 추적·미끄러짐 0
AActor::SetLifeSpan(Seconds)N초 후 자동 Destroy — 시체 정리
bCanEverTick = falseTick 함수 호출 중단 — 한 번 처리 후 자원 절약

G-5. Day 3 이후 확장 경로

Day 6 폴리시 시 추가 가능:

  • 래그돌 활성화: Zombie->GetMesh()->SetSimulatePhysics(true) (시각 만족도 ↑)
  • 점수 누적: OnDeath.AddDynamic(GameMode, &OnZombieKilled) 이벤트 기반 마이그레이션
  • 시체 콜리전 끄기: Zombie->GetCapsuleComponent()->SetCollisionEnabled(NoCollision) (다른 좀비가 시체 우회 안 하게)

H. Day 2 커밋 (예상 5분)

  • git add . && git commit -m "Day 2: Zombie AI BT + LineTrace fire + AIPerception hearing + zombie death"
  • git push

블로커 대응

  • NavMesh 안 보임: 레벨에 NavMeshBoundsVolume 추가 → P 키로 녹색 NavMesh 표시 확인. 없으면 좀비 MoveTo 실패
  • AI 컨트롤러 미빙의: ZombieCharacter Class Defaults → AI Controller Class = BP_VOIDZombieAIController + Auto Possess AI = Placed in World or Spawned 확인
  • BT가 안 돌아감: AVOIDZombieAIController::OnPossess()에서 RunBehaviorTree(BehaviorTreeAsset) 호출하는지 cpp 확인
  • BTTask_Attack BP가 EBTNodeResult 안 돌려줌: Finish Execute (Success=true) 노드 호출 필수
  • 사격이 좀비 통과: Pawn Profile + Trace Responses → Weapon = Block (커스텀 채널)
  • 죽은 좀비가 계속 공격: AIController Tick에 IsDead 폴링 누락 — G-3 코드 확인

11. 내일(4/27, Day 3) 작업 블록 — 체크리스트

Day 2를 4/27 새벽에 마무리 → 그대로 Day 3 진입. 원 계획 Day 3는 4/26였으나 1일 지연 누적 — 4/27~28 이틀에 Day 3+4 압축.

Day 3 완료 기준 (구현계획 233줄)

1층→2층 계단 Trigger 진입 자동 전환 + 아이템 1개 이상 픽업으로 무게 게이지 변화

A. 건물 3층 레벨 제작 (예상 90분)

A-1. 레벨 생성

  • Content/VoidUnreal/Maps/L_VoidProto 신규 생성 (또는 기존 NewWorld 활용)
  • World Settings → Game Mode Override = BP_VOIDGameMode
  • PlayerStart 1층 로비 입구에 배치

A-2. 1층 (로비) — 안전 파밍 학습

  • FAB 또는 UE5 기본 큐브로 외곽 벽 (가로 3000 × 세로 2500)
  • 카운터·식료품 선반 (StaticMesh 배치, 공짜 에셋)
  • 계단 → 2층 (StaticMesh 또는 BSP)
  • 조명 PointLight 3~4개 (어두운 톤)
  • NavMeshBoundsVolume: 1층 전체 + 계단 영역 포함 (Z=500)

A-3. 2층 (거주/사무) — 실내 탐색·복도

  • 1층보다 좁은 복도 + 방 2~3개 (가로 2500 × 세로 2000)
  • 책상·의자 배치 (StaticMesh)
  • 계단 → 3층(옥상)
  • PointLight 어두운 톤 (1층보다 어둡게)
  • NavMeshBoundsVolume: 2층 전체 (별도 볼륨 또는 1층과 통합 — Z 충분히 크게)

A-4. 3층 (옥상 주차장) — 차량 수리 + 호드 압박

  • 외부 노출 옥상 (가로 3500 × 세로 3000)
  • 차량 1대 (StaticMesh — UE5 자동차 또는 큐브 대체)
  • 차량 부품 스폰 포인트 3곳 (Empty Actor 또는 Marker)
  • DirectionalLight (옥상이라 밝게 — 1·2층과 대비)
  • NavMeshBoundsVolume: 옥상 전체
  • 각 계단 입구·출구에 NavLinkProxy 1쌍 배치
  • Smart Link → Direction = Both Ways (좀비 양방향 이동)
  • PIE에서 P 토글로 NavMesh 연결 확인

B. 계단 BoxTrigger — 층 자동 전환 (예상 30분)

B-1. AVOIDGameMode 상태 머신

  • AVOIDGameMode.h/.cpp에 추가:
    • int32 CurrentFloorIndex = 1
    • void AdvanceWave() — FloorIndex 증가 + GameState 알림 + 배너 메시지
    • void StartFloor(int32 FloorIndex) — 좀비 스폰 + 타이머 시작

B-2. BoxTrigger 액터 배치

  • 1→2층 계단 끝에 TriggerBox 배치 → OnComponentBeginOverlap에서 GameMode->AdvanceWave() 호출
  • 2→3층 계단 끝에도 동일
  • 진입 시 화면 메시지: “1층 진입 — 로비 파밍” / “2층 진입” / “옥상 진입”

C. ASpawnVolume + DT_VOIDWaveData (예상 30분)

C-1. SpawnVolume cpp

  • AVOIDSpawnVolume.cpp 본문 작성:
    • BoxComponent 안에서 FMath::RandRange로 위치 결정
    • SpawnActor<AVOIDZombieCharacter> 또는 파밍 아이템
    • FloorIndex별 ZombieCount/ItemCount 적용

C-2. DataTable 생성

  • Content/VoidUnreal/Data/DT_VOIDWaveData 신규
  • 행 3개 (FloorIndex 1·2·3) — TimeLimit/ZombieCount/ItemSpawnCount/FloorTheme/GoalType

C-3. 층별 스폰 포인트 배치

  • 1층: SpawnVolume 2개 (좀비 3 + 아이템 8)
  • 2층: SpawnVolume 3개 (좀비 8 + 아이템 12)
  • 3층: SpawnVolume 4개 (좀비 15 호드 + 부품 3)

D. UInventoryComponent 무게 시스템 + Press E 픽업 (예상 60분)

D-1. InventoryComponent cpp

  • UVOIDInventoryComponent.cpp::AddItem(FItemData) — TotalWeight 누적, MaxCarry 초과 시 false 반환
  • RemoveItem(), GetWeightRatio() (속도/소음 계산용)
  • OnWeightChanged.Broadcast(TotalWeight) 델리게이트 발동

D-2. 무게 → 이동/소음 동적 효과

  • AVOIDPlayerCharacter::Tick()에서 매 프레임:
    • WeightRatio = Inventory->GetWeightRatio()
    • MaxWalkSpeed = 450 * (1.0 - WeightRatio * 0.6)
    • NoiseComponent의 다음 EmitNoise WeightMultiplier에 1.0 + WeightRatio * 1.2 곱함

D-3. 파밍 아이템 3~4종

  • BP_Pickup_Food (무게 0.5kg, 점수 5)
  • BP_Pickup_Medkit (무게 3kg, 점수 20)
  • BP_Pickup_Ammo (무게 1kg, 점수 10)
  • 각 BP의 AVOIDPickupBase 자식, ItemDataAsset 연결

D-4. Press E 인터랙션

  • AVOIDPlayerCharacter::Interact() 본문 작성:
    • 캐릭터 앞 Sphere Trace 200 → IItemInterface 검색
    • 발견 시 OnPickedUp(this) 호출 → InventoryComponent.AddItem
  • 픽업 시 NoiseComponent.EmitNoise(Pickup) 호출

E. PIE 검증 (예상 15분)

  • 1층 PlayerStart 등장
  • 1층 좀비 3마리 자동 스폰 (SpawnVolume)
  • 사격 → 좀비 추적·전투 → 죽음 처리
  • 식료품·약 픽업 → HUD 무게 게이지 증가 (델리게이트 작동)
  • 무게 70% 이상 시 이동속도 체감 감소
  • 1층 계단 끝 → 자동으로 2층 진입 + “2층 진입” 배너
  • 2층 좀비 8마리 + 아이템 12개 자동 스폰

F. Day 3 커밋

  • git add . && git commit -m "Day 3: 3-floor level + Wave state machine + Inventory weight + Pickup E"
  • git push

Day 3 블로커 대응

  • 계단에서 좀비가 안 따라옴: NavLinkProxy의 Smart Link 켜져 있는지 + Direction Both Ways 확인
  • TriggerBox 안 작동: Collision Preset = Trigger (OverlapAllDynamic) + Box 크기 충분한지
  • InventoryComponent 무게 0 고정: ItemDataAsset의 Weight 값 입력했는지 확인
  • Press E 반응 없음: SetupPlayerInputComponent에 InteractAction 바인딩 확인 (이미 있음)
  • 2층 진입 시 배너 안 뜸: GameMode의 AdvanceWave()가 GameState OnWaveChanged 브로드캐스트하는지

Day 3 → Day 4 인계 사항

  • Day 4: 옥상 차량 수리 + 부품 3개 시스템 + 호드 스폰 + 누적 소음 아래층 전파 + ADS 조준
  • Day 3 체크리스트 6/7 완료, D-3(Food/Medkit/Ammo BP)는 부품 3종 DA 검증으로 대체
  • 차량 부품 3종 DataAsset(DA_Part_Battery 8kg / FuelTank 12kg / SparkPlug 2kg) 생성 완료 → Day 4 슬롯 인터랙션에 그대로 사용
  • Press E 인터페이스 패턴(Implements<UVOIDItemInterface> + Execute_FuncName)을 IVOIDVehiclePartSlot로 재사용 (필터만 교체)

12. 오늘(4/28, Day 4) 작업 블록 — 체크리스트

마감 1차 연장 반영: 8번 과제 + 필수 과제(샷건+반동) 합산 마감이 2026-05-01 (금). 같은 날 팀플(Ch3) 시작 — Day 4 산출물이 팀플 스캐폴딩(전투/무기 모듈)으로 전환됨. 우선순위는 필수 과제 우선: 샷건+반동 → 차량 슬롯 → 호드 → ADS → (도전) 비동기 Trace.

Day 4 완료 기준 (구현계획 234줄 + 마감 추가 사항)

(a) 옥상(Floor 3)에서 부품 3개 픽업 → 차량 시동 탈출 성공 화면 (b) 무게 8/12/2 kg 운반 디버프가 옥상 클라이맥스에 작용 (c) 샷건(Shotgun) 1개 + 카메라 반동(Pitch/Yaw 회복)이 동작

A. 샷건 + 반동 시스템 (필수 과제 — 예상 90분)

마감(05-01) 직결 항목. Day 4 첫 블록에 박아 일정 흔들림 방지. Void 본편/팀플 양쪽에서 재사용할 무기 모듈로 설계 — UVOIDWeaponConfig DataAsset 분리.

A-1. UVOIDWeaponConfig DataAsset 신설

  • Source/VoidUnreal/Public/Items/VOIDWeaponConfig.hUDataAsset 상속
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    
    UCLASS(BlueprintType)
    class VOIDUNREAL_API UVOIDWeaponConfig : public UDataAsset
    {
        GENERATED_BODY()
    public:
        UPROPERTY(EditAnywhere, Category="Weapon|Fire") int32 PelletCount = 1;       // 샷건 = 8
        UPROPERTY(EditAnywhere, Category="Weapon|Fire") float SpreadDegrees = 0.f;   // 샷건 = 6.0
        UPROPERTY(EditAnywhere, Category="Weapon|Fire") float Range = 5000.f;
        UPROPERTY(EditAnywhere, Category="Weapon|Fire") float DamagePerPellet = 12.f;
        UPROPERTY(EditAnywhere, Category="Weapon|Recoil") float RecoilPitch = 1.5f;  // deg, 발사 즉시
        UPROPERTY(EditAnywhere, Category="Weapon|Recoil") float RecoilYawJitter = 0.6f;
        UPROPERTY(EditAnywhere, Category="Weapon|Recoil") float RecoilRecoverPerSec = 4.f; // deg/s
        UPROPERTY(EditAnywhere, Category="Weapon|Noise") float NoiseRadius = 4500.f;
    };
    
  • DataAsset 인스턴스 2종 생성 (Content/VoidUnreal/Data/):
    • DA_Weapon_Rifle (Pellet 1, Spread 0, RecoilPitch 0.6, NoiseRadius 3000)
    • DA_Weapon_Shotgun (Pellet 8, Spread 6.0, RecoilPitch 2.5, RecoilYawJitter 1.2, NoiseRadius 4500)

A-2. AVOIDPlayerCharacter::Fire 리팩토링 — Config 주입

  • 헤더 추가:
    1
    2
    3
    4
    5
    6
    7
    
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Void|Weapon")
    TObjectPtr<UVOIDWeaponConfig> CurrentWeapon;
    
    UPROPERTY(VisibleAnywhere, Category="Void|Weapon")
    float PendingRecoilPitch = 0.f;
    UPROPERTY(VisibleAnywhere, Category="Void|Weapon")
    float PendingRecoilYaw = 0.f;
    
  • Fire() 본문에서 Config 없으면 폴백(기존 LineTrace 1발), 있으면 PelletCount만큼 루프
    1
    2
    3
    4
    5
    6
    7
    
    for (int32 i = 0; i < CurrentWeapon->PelletCount; ++i)
    {
        const FVector Forward = FollowCamera->GetForwardVector();
        const FVector Spread = FMath::VRandCone(Forward, FMath::DegreesToRadians(CurrentWeapon->SpreadDegrees));
        const FVector End = Start + Spread * CurrentWeapon->Range;
        // LineTrace → DamagePerPellet
    }
    
  • 발사 직후 반동 적립: PendingRecoilPitch += CurrentWeapon->RecoilPitch; / PendingRecoilYaw += FMath::FRandRange(-J, J);

A-3. 카메라 반동 적용 + 회복 (Tick)

  • Tick()에 반동 처리 블록 추가 (무게 갱신 블록 다음)
    1
    2
    3
    4
    5
    6
    7
    8
    
    if (PendingRecoilPitch > KINDA_SMALL_NUMBER)
    {
        AddControllerPitchInput(-PendingRecoilPitch);     // 카메라 위로 튐
        AddControllerYawInput(PendingRecoilYaw);
        const float Recover = CurrentWeapon ? CurrentWeapon->RecoilRecoverPerSec : 4.f;
        PendingRecoilPitch = FMath::FInterpTo(PendingRecoilPitch, 0.f, DeltaTime, Recover);
        PendingRecoilYaw   = FMath::FInterpTo(PendingRecoilYaw,   0.f, DeltaTime, Recover);
    }
    
  • 1프레임에 반동 입력 → 다음 프레임부터 회복 보간 (시각적으로 “튀고 → 흘러내림”)

A-4. 무게 / 소음 연동 (기존 D-2 흐름 보존)

  • 샷격 발사 1회당 EmitNoise(ENoiseType::Gunshot, (1 + WeightRatio*1.2) * (CurrentWeapon->NoiseRadius / 3000.f)) — 샷건이 라이플보다 1.5배 시끄럽게
  • 만재 30 kg + 샷건 = 소음 반경 약 7400 → 옥상 호드 트리거 직결

A-5. 검증

  • 라이플 PIE: 1발/저반동/낮은 소음
  • 샷건 PIE: 1발에 8 펠릿 분산 + 카메라 위로 ~2.5도 튐 + 0.6초 안에 회복 + 소음 반경 디버그 시각화 확장 확인

B. 차량 부품 슬롯 인터랙션 (예상 70분)

Day 3 인터페이스 패턴 그대로 재사용. 슬롯 3개 = 부품 3개 1:1 대응.

B-1. IVOIDVehiclePartSlot 인터페이스

  • Source/VoidUnreal/Public/Items/VOIDVehiclePartSlot.h
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    
    UINTERFACE(MinimalAPI, Blueprintable)
    class UVOIDVehiclePartSlot : public UInterface { GENERATED_BODY() };
    
    class VOIDUNREAL_API IVOIDVehiclePartSlot
    {
        GENERATED_BODY()
    public:
        UFUNCTION(BlueprintNativeEvent, Category="VehicleSlot")
        EVOIDPartType GetRequiredPartType() const;
    
        UFUNCTION(BlueprintNativeEvent, Category="VehicleSlot")
        bool TryInstallPart(UVOIDItemDataAsset* Part, AActor* Installer);
    
        UFUNCTION(BlueprintNativeEvent, Category="VehicleSlot")
        bool IsInstalled() const;
    };
    
  • EVOIDPartType enum: Battery / FuelTank / SparkPlug

B-2. AVOIDVehiclePartSlot 액터

  • Source/VoidUnreal/Public/Items/VOIDVehiclePartSlot.hAActor + IVOIDVehiclePartSlot
  • 멤버: RequiredType, bInstalled, InstalledMesh(시각 피드백)
  • TryInstallPart_Implementation 흐름:
    1. Part->PartType != RequiredType → false (HUD에 “부품 종류 불일치”)
    2. Installer의 InventoryComponent에서 해당 부품 1개 차감 (RemoveItem(Part))
    3. bInstalled = trueInstalledMesh->SetVisibility(true)
    4. AVOIDVehicle::OnSlotInstalled.Broadcast(RequiredType) → 수리 진행도 갱신

B-3. AVOIDVehicle 차량 액터

  • 슬롯 3개를 자식 컴포넌트로 보유 (Battery/FuelTank/SparkPlug)
  • int32 InstalledCount, 3 도달 시 OnRepairComplete.Broadcast() → “Press F: 시동” 프롬프트
  • 시동 입력 → AVOIDGameMode::HandleEscapeSuccess() → 탈출 성공 화면

B-4. Press E 인터랙션 확장

  • AVOIDPlayerCharacter::Interact()에서 기존 Sphere Trace 결과를 두 인터페이스로 분기 처리
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    if (Best->Implements<UVOIDVehiclePartSlot>())
    {
        // 인벤토리에서 RequiredType 부품 찾기
        UVOIDItemDataAsset* Part = Inventory->FindPartByType(SlotType);
        if (Part) IVOIDVehiclePartSlot::Execute_TryInstallPart(Best, Part, this);
    }
    else if (Best->Implements<UVOIDItemInterface>())
    {
        // 기존 픽업 경로
    }
    

B-5. 검증

  • 옥상에 슬롯 3개 + 부품 3개 배치
  • 부품 3개 픽업 시 무게 22 kg 도달 → 이동속도 ~270, 소음 +88%
  • 슬롯 앞에서 E → 종류 일치 시 설치, 불일치 시 메시지
  • 3개 모두 설치 → “시동” 프롬프트 → 탈출 성공 화면 트리거

C. 호드(Horde) 시스템 (예상 50분)

무게/소음 시스템과 직결. 옥상 클라이맥스의 압박을 만드는 핵심.

C-1. EVOIDHordeTrigger 트리거 조건

  • 다음 중 하나라도 충족 시 호드 시작:
    • Floor 3 진입 후 30초 경과
    • 옥상에서 누적 소음 ≥ 임계값(HordeNoiseThreshold = 80)
    • 부품 1개 이상 설치 (수리 시작 = 대놓고 좌표 노출)

C-2. AVOIDGameMode::StartHorde() 구현

  • 옥상 SpawnVolume 4개에서 좀비 추가 스폰 (5초 간격, 3 웨이브 × 5마리 = 15마리)
  • 1·2층 잔여 좀비를 MoveTo(옥상 계단 입구) 명령 → 누적 소음 전파 효과를 “물리적 추격”으로 가시화
  • HUD: “HORDE INCOMING” 빨간 배너 1.5초 슬라이드 (Day 5 UI 작업과 연동)

C-3. 누적 소음 → 아래층 전파 (FloorIndex 비교)

  • UVOIDNoiseComponent::BroadcastNoise에 FloorIndex 차감 로직 추가
    1
    2
    3
    4
    
    // 좀비가 N층, 플레이어가 M층(M > N)일 때 감쇠 0.3~0.5
    const int32 FloorDiff = PlayerFloor - ZombieFloor;
    const float Attenuation = FloorDiff > 0 ? FMath::Pow(0.4f, FloorDiff) : 1.f;
    EffectiveRadius = Radius * Attenuation;
    
  • 옥상 사격 → 1·2층 좀비도 청각 트리거 → 계단 NavLink 따라 이동 → 호드와 합류

C-4. 검증

  • 옥상 진입 30초 → 자동 호드
  • 옥상 사격 → 2층 좀비가 계단으로 올라옴 (시각 확인)
  • 부품 1개 설치 → 즉시 호드 트리거 (이른 호드)

D. ADS 조준 시스템 (예상 40분)

우선순위 마지막. 시간 부족 시 Day 5로 이월 가능 (마감 직결 아님).

D-1. 입력 추가

  • IA_Aim (Bool) 신규 + IMC에 마우스 우클릭 매핑
  • SetupPlayerInputComponentStarted/Completed 양쪽 바인딩

D-2. ADS 상태 + FOV/SpringArm 보간

  • bIsAiming 플래그
  • Tick에서 보간:
    1
    2
    3
    4
    5
    6
    7
    
    const float TargetFOV = bIsAiming ? 55.f : 90.f;
    FollowCamera->FieldOfView = FMath::FInterpTo(FollowCamera->FieldOfView, TargetFOV, DeltaTime, 8.f);
    
    const float TargetArm = bIsAiming ? 180.f : 300.f;
    CameraBoom->TargetArmLength = FMath::FInterpTo(CameraBoom->TargetArmLength, TargetArm, DeltaTime, 8.f);
    
    GetCharacterMovement()->MaxWalkSpeed *= bIsAiming ? 0.6f : 1.0f;  // 무게 감속과 곱셈 (Tick 순서 주의)
    

D-3. 정확도 보정

  • Fire()SpreadDegreesbIsAiming ? 0.3f : 1.0f 곱하기 — ADS 시 산탄도 1/3로 압축

D-4. 검증

  • 우클릭 → 0.3초 안에 어깨로 카메라 당겨오고 FOV 좁아짐
  • ADS 중 이동 시 속도 -40%
  • ADS 샷건 → 산탄 패턴 좁아짐 + 명중률 향상

E. (도전) 비동기 Trace 적 AI 탐지 (예상 60분 — 시간 여유 시)

도전 과제 + 호드 시스템 확장. 우선순위 최하 — Day 5 도전 블록에서 진행.

E-1. 비동기 시야 Trace

  • AVOIDZombieAIController::Tick에서 0.2초마다 UWorld::AsyncLineTraceByChannel(EAsyncTraceType::Single, ...) 호출
  • FTraceDelegate 콜백에 결과 처리 — Hit이 Player면 BB["TargetActor"] = Player; bHasTarget=true

E-2. AISense_Sight와의 분리

  • AISense_Sight는 그대로 유지(Day 4 옥상 야외에서 시야 효과)
  • 비동기 LineTrace는 간이 1대1 시야 검증(벽 뒤 차폐)을 추가하는 보조 — 시야 거짓 양성 줄임

E-3. 검증

  • PIE에서 좀비가 벽 뒤 플레이어 못 봄
  • Visual Logger로 비동기 결과 시계열 확인

F. Day 4 커밋 (예상 5분)

  • git add . && git commit -m "Day 4: Shotgun+Recoil + Vehicle slots + Horde + ADS"
  • git push

Day 4 우선순위 정렬 (마감 05-01 압박)

순위블록이유시간
1A. 샷건+반동필수 과제 합산 마감(05-01) — 미완 시 점수 직격90분
2B. 차량 슬롯Day 4 완료 기준(a) — 옥상 탈출 미완 시 게임 미완성70분
3C. 호드Day 4 완료 기준(b)·”Noise is Currency” 본 검증50분
4D. ADSTPS 완성도 + 평가 우수성 — 시간 부족 시 Day 5 이월 가능40분
5E. (도전) 비동기 Trace도전 과제 — Day 5 도전 블록에서 진행 권장60분

Day 4 디버깅 예상 포인트

  • 반동이 너무 셈 / 회복이 끊김: RecoilRecoverPerSec이 너무 작음. 4 → 8로 올리고 RecoilPitch는 0.5도 단위로 튜닝
  • 샷건 펠릿이 한 곳에 다 박힘: FMath::VRandCone의 라디안 변환 누락. FMath::DegreesToRadians 확인
  • 슬롯에 부품 못 꽂음: 인벤토리 FindPartByType 미구현 또는 ItemData->PartType 필드 누락. DataAsset 인스턴스에 PartType 값 설정 확인
  • 호드가 안 옴: AISense_Hearing의 MaxRange가 옥상-1층 거리(2000+)보다 작음. NoiseConfig 재확인 + FloorDiff 감쇠 너무 빡빡한지 점검
  • ADS Tick 순서 충돌: 무게 감속(D-2 Tick)과 ADS 감속(D-2)이 같은 프레임에 MaxWalkSpeed 두 번 덮어씀. 한 곳에서 곱셈 합산 처리(Base * (1-WR*0.6) * (bAds ? 0.6 : 1))
  • 비동기 Trace 콜백에서 크래시: Trace 결과 받기 전에 좀비가 Destroy되면 this 댕글링. TWeakObjectPtr 캡처로 회피

Day 4 → Day 5 인계 사항 (팀플 스캐폴딩 전환 시작)

  • 샷건 + 반동 시스템: 팀플(05-01 시작) 전투 모듈로 그대로 이식 — UVOIDWeaponConfigUTeamWeaponConfig 리네임만
  • 차량 슬롯 인터페이스 패턴: 팀플의 처형/상호작용 슬롯 시스템 베이스로 재활용
  • 호드 트리거 로직: 팀플 웨이브 로그라이트의 “압도 페이즈” 베이스
  • HUD/UI는 Day 5에 통합 — 무게·소음·웨이브·반동 회복 인디케이터까지 한 화면

13. 남은 일정 (2026-04-28 기준 — 4일 남음)

마감을 4/30 → 05-01로 이동. 같은 날 팀플 시작 → Day 6는 8번 과제 마무리 + 팀플 킥오프 병행.

날짜작업산출물마감 압박
4/28 (화, Day 4) ← 오늘샷건+반동 → 차량 슬롯 → 호드 → ADS옥상 탈출 흐름 + 샷건 작동필수 과제 1차 마감
4/29 (수, Day 5)(도전) 비동기 Trace AI / WBP_VOIDHUD (체력·무게·소음·반동·웨이브·시간) / 메인·게임오버·탈출 성공 메뉴 / 입력 모드 전환UI 완성 + 도전 과제 1종알고리즘 과제 마감(LeetCode 912)
4/30 (목, Day 6)디버프 3종 HUD + UI 애니메이션(비네트 펄스·무게 경고·웨이브 배너·Fade) / 3D 위젯(부품 인디케이터·Press E 프롬프트) / 파티클·SFX 최소 / 팀플 킥오프 사전 준비 (8번 모듈 → 팀플 레포 이식 계획)도전 1·2·3·4 + 팀장 사전 자료팀플 시작 D-1
5/01 (금, Day 7 = 마감 + 팀플 시작)밸런싱(웨이브 난이도·소음 수치·무게 임계값·반동 회복 속도) / 시연 영상 녹화·편집 / README 완성 / 태그 + 제출 / 팀플 킥오프 미팅8번 과제 제출 + 팀플 Day 08번 과제 + 샷건+반동 합산 마감

Buffer & 리스크 (갱신)

  • 4/28 Day 4 블로커가 가장 위험 — 샷건+반동 미완 시 5/01까지 누적, 팀플 시작과 충돌. 저녁까지 A·B 블록 PIE 검증 필수
  • 4/29 Day 5는 UI 통합 + 도전 — 도전 과제 1종(비동기 Trace OR 디버프 3종)만 깊이 있게
  • 4/30 Day 6는 팀플 킥오프 D-1 — 새 기능보다 마무리 + 팀플 인계 자료 우선
  • 5/01은 제출 + 팀플 시작 동시일 — 새 기능 절대 금지. 시연 영상·README·태그·팀플 킥오프만

하루별 완료 기준 (갱신)

| Day | 내일로 넘어갈 수 있는 최소 기준 | |—|—| | Day 4 (4/28) | 샷건 1발에 8펠릿 분산·카메라 반동·회복 + 옥상 부품 3개 픽업·슬롯 설치·시동 → 탈출 성공 화면 | | Day 5 (4/29) | HUD 전 게이지 실시간 바인딩(반동 회복 인디케이터 포함) + 3개 메뉴 동작 + 도전 1종 작동 | | Day 6 (4/30) | 디버프 3종 HUD 표시 + 3D 위젯 부품 인디케이터 + 팀플 인계 자료 1쪽 | | Day 7 (5/01) | 10분 시연 영상 + README + GitHub Release 태그 + 팀플 킥오프 참여 |

  • 인벤토리 무게 시스템이 차량 부품(8/12/2kg) 들고 옮길 때 핵심 디버프로 작용

§14. Day 4 코드 구현 결과 + 리뷰 + 에디터 작업

작성: 2026-04-28. 본 워크스페이스는 8번 과제 본 레포(NBC_JangSik_Assignment8)가 아니므로 실제 .h/.cpp 파일은 만들지 않는다. 아래 코드 블록을 그대로 8번 과제 레포의 명시 경로에 복사해서 컴파일하면 된다. 모듈명은 VOIDUNREAL(매크로 VOIDUNREAL_API), 클래스 접두사는 VOID, 인터페이스는 Day 3에서 정착한 BlueprintNativeEvent + Implements<U...> + Execute_F 패턴을 유지한다. 기존 UVOIDItemDataAsset, UVOIDInventoryComponent, UVOIDNoiseComponent, IVOIDItemInterface(Day 3) 의존을 가정한다.

14-1. 작성된 코드 (그대로 8번 과제 레포에 붙여넣기)

A. 샷건 + 반동 시스템

A-1. UVOIDWeaponConfig (DataAsset)
  • 경로: Source/VoidUnreal/Public/Items/VOIDWeaponConfig.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#pragma once

#include "CoreMinimal.h"
#include "Engine/DataAsset.h"
#include "VOIDWeaponConfig.generated.h"

UENUM(BlueprintType)
enum class EVOIDWeaponClass : uint8
{
    Rifle    UMETA(DisplayName = "Rifle"),
    Shotgun  UMETA(DisplayName = "Shotgun"),
    Pistol   UMETA(DisplayName = "Pistol")
};

UCLASS(BlueprintType)
class VOIDUNREAL_API UVOIDWeaponConfig : public UDataAsset
{
    GENERATED_BODY()

public:
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Weapon|Identity")
    EVOIDWeaponClass WeaponClass = EVOIDWeaponClass::Rifle;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Weapon|Identity")
    FName WeaponId = NAME_None;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Weapon|Fire", meta = (ClampMin = "1"))
    int32 PelletCount = 1;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Weapon|Fire", meta = (ClampMin = "0.0"))
    float SpreadDegrees = 0.f;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Weapon|Fire", meta = (ClampMin = "0.0"))
    float Range = 5000.f;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Weapon|Fire", meta = (ClampMin = "0.0"))
    float DamagePerPellet = 12.f;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Weapon|Fire", meta = (ClampMin = "0.05"))
    float FireInterval = 0.12f;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Weapon|Recoil", meta = (ClampMin = "0.0"))
    float RecoilPitch = 1.5f;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Weapon|Recoil", meta = (ClampMin = "0.0"))
    float RecoilYawJitter = 0.6f;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Weapon|Recoil", meta = (ClampMin = "0.1"))
    float RecoilRecoverPerSec = 4.f;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Weapon|Noise", meta = (ClampMin = "0.0"))
    float NoiseRadius = 4500.f;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Weapon|FX")
    TSoftObjectPtr<class USoundBase> FireSound;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Weapon|FX")
    TSoftObjectPtr<class UCameraShakeBase> CameraShakeClass;
};
  • 경로: Source/VoidUnreal/Private/Items/VOIDWeaponConfig.cpp
1
2
#include "Items/VOIDWeaponConfig.h"
// DataAsset 전용 — 정의 없음 (UPROPERTY 기본값으로 충분)
A-2. AVOIDPlayerCharacter 사격/반동 확장 (Day 1~3에서 이미 존재한다고 가정 — 추가/변경 부분만 표기)
  • 경로: Source/VoidUnreal/Public/Characters/VOIDPlayerCharacter.h (추가 멤버만)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 기존 헤더의 public 영역에 추가
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Void|Weapon")
TObjectPtr<class UVOIDWeaponConfig> CurrentWeapon;

UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = "Void|Weapon")
float PendingRecoilPitch = 0.f;

UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = "Void|Weapon")
float PendingRecoilYaw = 0.f;

UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = "Void|Weapon")
float LastFireTime = -1000.f;

UFUNCTION(BlueprintCallable, Category = "Void|Weapon")
void Fire();

UFUNCTION(BlueprintCallable, Category = "Void|Weapon")
void EquipWeapon(UVOIDWeaponConfig* NewWeapon);

protected:
    void TickRecoil(float DeltaTime);
    void ApplyHitDamage(const struct FHitResult& Hit, float Damage);
  • 경로: Source/VoidUnreal/Private/Characters/VOIDPlayerCharacter.cpp (추가 메서드만)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
#include "Characters/VOIDPlayerCharacter.h"
#include "Items/VOIDWeaponConfig.h"
#include "Components/VOIDInventoryComponent.h"
#include "Components/VOIDNoiseComponent.h"
#include "Camera/CameraComponent.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "Kismet/GameplayStatics.h"
#include "Engine/World.h"
#include "DrawDebugHelpers.h"

void AVOIDPlayerCharacter::EquipWeapon(UVOIDWeaponConfig* NewWeapon)
{
    CurrentWeapon = NewWeapon;
    PendingRecoilPitch = 0.f;
    PendingRecoilYaw = 0.f;
}

void AVOIDPlayerCharacter::Fire()
{
    if (!IsValid(FollowCamera)) return;

    const float Now = GetWorld()->GetTimeSeconds();
    const float Interval = CurrentWeapon ? CurrentWeapon->FireInterval : 0.15f;
    if (Now - LastFireTime < Interval) return;
    LastFireTime = Now;

    const FVector Start = FollowCamera->GetComponentLocation();
    const FVector Forward = FollowCamera->GetForwardVector();

    const int32 Pellets   = CurrentWeapon ? CurrentWeapon->PelletCount    : 1;
    const float SpreadDeg = CurrentWeapon ? CurrentWeapon->SpreadDegrees  : 0.f;
    const float Range     = CurrentWeapon ? CurrentWeapon->Range          : 5000.f;
    const float Damage    = CurrentWeapon ? CurrentWeapon->DamagePerPellet: 20.f;

    // ADS 보정 (D 블록 bIsAiming 멤버 의존)
    const float SpreadMul = bIsAiming ? 0.3f : 1.f;
    const float SpreadRad = FMath::DegreesToRadians(SpreadDeg * SpreadMul);

    FCollisionQueryParams Params(SCENE_QUERY_STAT(VOIDFire), false, this);
    Params.bReturnPhysicalMaterial = false;

    for (int32 i = 0; i < Pellets; ++i)
    {
        const FVector Dir = (Pellets == 1)
            ? Forward
            : FMath::VRandCone(Forward, SpreadRad);
        const FVector End = Start + Dir * Range;

        FHitResult Hit;
        const bool bHit = GetWorld()->LineTraceSingleByChannel(
            Hit, Start, End, ECC_Visibility, Params);

#if !(UE_BUILD_SHIPPING)
        if (bDrawWeaponDebug)
        {
            DrawDebugLine(GetWorld(), Start, bHit ? Hit.ImpactPoint : End,
                bHit ? FColor::Red : FColor::Green, false, 0.4f, 0, 1.f);
        }
#endif
        if (bHit) ApplyHitDamage(Hit, Damage);
    }

    // 반동 적립
    if (CurrentWeapon)
    {
        PendingRecoilPitch += CurrentWeapon->RecoilPitch;
        const float J = CurrentWeapon->RecoilYawJitter;
        PendingRecoilYaw  += FMath::FRandRange(-J, J);
    }

    // 무게 → 소음 연동 (Day 3 NoiseComponent 흐름 보존)
    if (NoiseComponent && CurrentWeapon)
    {
        const float WeightRatio = Inventory ? Inventory->GetWeightRatio() : 0.f;
        const float NoiseScale  = (1.f + WeightRatio * 1.2f) * (CurrentWeapon->NoiseRadius / 3000.f);
        NoiseComponent->BroadcastNoise(ENoiseType::Gunshot, NoiseScale);
    }

    if (CurrentWeapon && CurrentWeapon->FireSound.IsValid())
    {
        UGameplayStatics::PlaySoundAtLocation(this, CurrentWeapon->FireSound.Get(), Start);
    }
}

void AVOIDPlayerCharacter::TickRecoil(float DeltaTime)
{
    if (PendingRecoilPitch > KINDA_SMALL_NUMBER || FMath::Abs(PendingRecoilYaw) > KINDA_SMALL_NUMBER)
    {
        AddControllerPitchInput(-PendingRecoilPitch);
        AddControllerYawInput(PendingRecoilYaw);

        const float Recover = CurrentWeapon ? CurrentWeapon->RecoilRecoverPerSec : 4.f;
        PendingRecoilPitch = FMath::FInterpTo(PendingRecoilPitch, 0.f, DeltaTime, Recover);
        PendingRecoilYaw   = FMath::FInterpTo(PendingRecoilYaw,   0.f, DeltaTime, Recover);
    }
}

void AVOIDPlayerCharacter::ApplyHitDamage(const FHitResult& Hit, float Damage)
{
    if (!Hit.GetActor()) return;
    UGameplayStatics::ApplyPointDamage(
        Hit.GetActor(), Damage,
        (Hit.ImpactPoint - GetActorLocation()).GetSafeNormal(),
        Hit, GetController(), this, nullptr);
}

// 기존 Tick에 한 줄 추가:
//   TickRecoil(DeltaTime);

B. 차량 부품 슬롯 인터랙션

B-1. EVOIDPartType enum + IVOIDVehiclePartSlot 인터페이스
  • 경로: Source/VoidUnreal/Public/Items/VOIDPartType.h
1
2
3
4
5
6
7
8
9
10
11
12
13
#pragma once

#include "CoreMinimal.h"
#include "VOIDPartType.generated.h"

UENUM(BlueprintType)
enum class EVOIDPartType : uint8
{
    None       UMETA(DisplayName = "None"),
    Battery    UMETA(DisplayName = "Battery"),
    FuelTank   UMETA(DisplayName = "FuelTank"),
    SparkPlug  UMETA(DisplayName = "SparkPlug")
};
  • 경로: Source/VoidUnreal/Public/Items/VOIDVehiclePartSlot.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#pragma once

#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "Items/VOIDPartType.h"
#include "VOIDVehiclePartSlot.generated.h"

class UVOIDItemDataAsset;

UINTERFACE(MinimalAPI, Blueprintable)
class UVOIDVehiclePartSlot : public UInterface
{
    GENERATED_BODY()
};

class VOIDUNREAL_API IVOIDVehiclePartSlot
{
    GENERATED_BODY()

public:
    UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "VehicleSlot")
    EVOIDPartType GetRequiredPartType() const;

    UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "VehicleSlot")
    bool TryInstallPart(UVOIDItemDataAsset* Part, AActor* Installer);

    UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "VehicleSlot")
    bool IsInstalled() const;
};
  • 경로: Source/VoidUnreal/Private/Items/VOIDVehiclePartSlot.cpp
1
2
#include "Items/VOIDVehiclePartSlot.h"
// 인터페이스 전용 — 본문 없음. _Implementation은 구현 액터에서 정의
B-2. AVOIDVehiclePartSlot 액터
  • 경로: Source/VoidUnreal/Public/Vehicle/VOIDVehiclePartSlotActor.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Items/VOIDVehiclePartSlot.h"
#include "VOIDVehiclePartSlotActor.generated.h"

class UStaticMeshComponent;
class USphereComponent;
class AVOIDVehicle;

UCLASS()
class VOIDUNREAL_API AVOIDVehiclePartSlotActor : public AActor, public IVOIDVehiclePartSlot
{
    GENERATED_BODY()

public:
    AVOIDVehiclePartSlotActor();

    // IVOIDVehiclePartSlot
    virtual EVOIDPartType GetRequiredPartType_Implementation() const override { return RequiredType; }
    virtual bool TryInstallPart_Implementation(UVOIDItemDataAsset* Part, AActor* Installer) override;
    virtual bool IsInstalled_Implementation() const override { return bInstalled; }

protected:
    UPROPERTY(VisibleAnywhere, Category = "VehicleSlot")
    TObjectPtr<USphereComponent> InteractionVolume;

    UPROPERTY(VisibleAnywhere, Category = "VehicleSlot")
    TObjectPtr<UStaticMeshComponent> EmptyMesh;

    UPROPERTY(VisibleAnywhere, Category = "VehicleSlot")
    TObjectPtr<UStaticMeshComponent> InstalledMesh;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "VehicleSlot")
    EVOIDPartType RequiredType = EVOIDPartType::Battery;

    UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = "VehicleSlot")
    bool bInstalled = false;

    UPROPERTY(EditInstanceOnly, BlueprintReadWrite, Category = "VehicleSlot")
    TWeakObjectPtr<AVOIDVehicle> OwnerVehicle;
};
  • 경로: Source/VoidUnreal/Private/Vehicle/VOIDVehiclePartSlotActor.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include "Vehicle/VOIDVehiclePartSlotActor.h"
#include "Vehicle/VOIDVehicle.h"
#include "Items/VOIDItemDataAsset.h"
#include "Components/VOIDInventoryComponent.h"
#include "Components/SphereComponent.h"
#include "Components/StaticMeshComponent.h"

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

    InteractionVolume = CreateDefaultSubobject<USphereComponent>(TEXT("InteractionVolume"));
    SetRootComponent(InteractionVolume);
    InteractionVolume->InitSphereRadius(120.f);
    InteractionVolume->SetCollisionProfileName(TEXT("OverlapAllDynamic"));

    EmptyMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("EmptyMesh"));
    EmptyMesh->SetupAttachment(InteractionVolume);
    EmptyMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);

    InstalledMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("InstalledMesh"));
    InstalledMesh->SetupAttachment(InteractionVolume);
    InstalledMesh->SetVisibility(false);
    InstalledMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
}

bool AVOIDVehiclePartSlotActor::TryInstallPart_Implementation(UVOIDItemDataAsset* Part, AActor* Installer)
{
    if (bInstalled || !IsValid(Part) || !IsValid(Installer)) return false;
    if (Part->PartType != RequiredType) return false;

    UVOIDInventoryComponent* Inv = Installer->FindComponentByClass<UVOIDInventoryComponent>();
    if (!IsValid(Inv) || !Inv->RemoveItem(Part, 1)) return false;

    bInstalled = true;
    if (EmptyMesh)     EmptyMesh->SetVisibility(false);
    if (InstalledMesh) InstalledMesh->SetVisibility(true);

    if (OwnerVehicle.IsValid())
    {
        OwnerVehicle->NotifySlotInstalled(RequiredType);
    }
    return true;
}
B-3. AVOIDVehicle 차량 액터
  • 경로: Source/VoidUnreal/Public/Vehicle/VOIDVehicle.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Items/VOIDPartType.h"
#include "VOIDVehicle.generated.h"

class UStaticMeshComponent;
class UBoxComponent;
class AVOIDVehiclePartSlotActor;

DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSlotInstalled, EVOIDPartType, PartType);
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnRepairComplete);

UCLASS()
class VOIDUNREAL_API AVOIDVehicle : public AActor
{
    GENERATED_BODY()

public:
    AVOIDVehicle();

    UFUNCTION(BlueprintCallable, Category = "Vehicle")
    void NotifySlotInstalled(EVOIDPartType PartType);

    UFUNCTION(BlueprintCallable, Category = "Vehicle")
    bool TryStartEngine(AActor* Driver);

    UPROPERTY(BlueprintAssignable, Category = "Vehicle")
    FOnSlotInstalled OnSlotInstalled;

    UPROPERTY(BlueprintAssignable, Category = "Vehicle")
    FOnRepairComplete OnRepairComplete;

protected:
    UPROPERTY(VisibleAnywhere, Category = "Vehicle")
    TObjectPtr<UStaticMeshComponent> Body;

    UPROPERTY(VisibleAnywhere, Category = "Vehicle")
    TObjectPtr<UBoxComponent> StartEngineVolume;

    UPROPERTY(EditInstanceOnly, BlueprintReadWrite, Category = "Vehicle")
    TArray<TWeakObjectPtr<AVOIDVehiclePartSlotActor>> PartSlots;

    UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = "Vehicle")
    int32 InstalledCount = 0;

    UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = "Vehicle")
    bool bRepairComplete = false;
};
  • 경로: Source/VoidUnreal/Private/Vehicle/VOIDVehicle.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include "Vehicle/VOIDVehicle.h"
#include "Vehicle/VOIDVehiclePartSlotActor.h"
#include "Components/StaticMeshComponent.h"
#include "Components/BoxComponent.h"
#include "Kismet/GameplayStatics.h"
#include "GameFramework/GameModeBase.h"

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

    Body = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Body"));
    SetRootComponent(Body);

    StartEngineVolume = CreateDefaultSubobject<UBoxComponent>(TEXT("StartEngineVolume"));
    StartEngineVolume->SetupAttachment(Body);
    StartEngineVolume->SetBoxExtent(FVector(120.f, 120.f, 80.f));
    StartEngineVolume->SetCollisionProfileName(TEXT("OverlapAllDynamic"));
}

void AVOIDVehicle::NotifySlotInstalled(EVOIDPartType PartType)
{
    ++InstalledCount;
    OnSlotInstalled.Broadcast(PartType);

    if (!bRepairComplete && InstalledCount >= 3)
    {
        bRepairComplete = true;
        OnRepairComplete.Broadcast();
    }
}

bool AVOIDVehicle::TryStartEngine(AActor* Driver)
{
    if (!bRepairComplete) return false;

    // GameMode에 탈출 성공 알림 — Day 5에서 HandleEscapeSuccess() 본 구현
    if (AGameModeBase* GM = UGameplayStatics::GetGameMode(this))
    {
        // Cast<AVOIDGameMode>(GM)->HandleEscapeSuccess(Driver);
        UE_LOG(LogTemp, Display, TEXT("[Vehicle] Engine started by %s"), *GetNameSafe(Driver));
    }
    return true;
}
B-4. Interact() 분기 (PlayerCharacter — 추가만)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void AVOIDPlayerCharacter::Interact()
{
    AActor* Best = FindBestInteractable(); // Day 3 Sphere Trace 결과

    if (!IsValid(Best)) return;

    if (Best->Implements<UVOIDVehiclePartSlot>())
    {
        const EVOIDPartType SlotType = IVOIDVehiclePartSlot::Execute_GetRequiredPartType(Best);
        if (UVOIDItemDataAsset* Part = Inventory ? Inventory->FindPartByType(SlotType) : nullptr)
        {
            const bool bOk = IVOIDVehiclePartSlot::Execute_TryInstallPart(Best, Part, this);
            UE_LOG(LogTemp, Display, TEXT("[Interact] Install %s = %s"),
                *UEnum::GetValueAsString(SlotType), bOk ? TEXT("OK") : TEXT("FAIL"));
        }
        return;
    }

    if (Best->Implements<UVOIDItemInterface>())
    {
        IVOIDItemInterface::Execute_OnPickup(Best, this); // Day 3 픽업 경로
    }
}

C. 호드 시스템

C-1. EVOIDHordeTrigger + AVOIDHordeManager
  • 경로: Source/VoidUnreal/Public/Game/VOIDHordeManager.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "VOIDHordeManager.generated.h"

class AVOIDSpawnVolume;

UENUM(BlueprintType)
enum class EVOIDHordeTrigger : uint8
{
    None             UMETA(DisplayName = "None"),
    Floor3Timer      UMETA(DisplayName = "Floor3 Timer"),
    NoiseAccumulated UMETA(DisplayName = "Noise Accumulated"),
    PartInstalled    UMETA(DisplayName = "Part Installed")
};

UCLASS()
class VOIDUNREAL_API AVOIDHordeManager : public AActor
{
    GENERATED_BODY()

public:
    AVOIDHordeManager();

    UFUNCTION(BlueprintCallable, Category = "Horde")
    void NotifyFloorEntered(int32 FloorIndex);

    UFUNCTION(BlueprintCallable, Category = "Horde")
    void AddNoiseAccumulation(float Amount);

    UFUNCTION(BlueprintCallable, Category = "Horde")
    void NotifyPartInstalled();

    UFUNCTION(BlueprintCallable, Category = "Horde")
    void StartHorde(EVOIDHordeTrigger Trigger);

protected:
    virtual void BeginPlay() override;
    virtual void Tick(float DeltaTime) override;

    UPROPERTY(EditAnywhere, Category = "Horde|Tuning")
    float Floor3GraceSeconds = 30.f;

    UPROPERTY(EditAnywhere, Category = "Horde|Tuning")
    float HordeNoiseThreshold = 80.f;

    UPROPERTY(EditAnywhere, Category = "Horde|Tuning")
    int32 SpawnsPerWave = 5;

    UPROPERTY(EditAnywhere, Category = "Horde|Tuning")
    int32 WaveCount = 3;

    UPROPERTY(EditAnywhere, Category = "Horde|Tuning")
    float WaveInterval = 5.f;

    UPROPERTY(EditAnywhere, Category = "Horde|Refs")
    TArray<TSoftObjectPtr<AVOIDSpawnVolume>> RoofSpawnVolumes;

    UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = "Horde|State")
    bool bHordeStarted = false;

    UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = "Horde|State")
    float Floor3EnteredTime = -1.f;

    UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = "Horde|State")
    float NoiseAccumulator = 0.f;

private:
    void SpawnWave(int32 WaveIndex);
    FTimerHandle WaveTimer;
    int32 CurrentWaveIndex = 0;
};
  • 경로: Source/VoidUnreal/Private/Game/VOIDHordeManager.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
#include "Game/VOIDHordeManager.h"
#include "Spawn/VOIDSpawnVolume.h"
#include "TimerManager.h"
#include "Engine/World.h"

AVOIDHordeManager::AVOIDHordeManager()
{
    PrimaryActorTick.bCanEverTick = true;
}

void AVOIDHordeManager::BeginPlay()
{
    Super::BeginPlay();
}

void AVOIDHordeManager::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    if (bHordeStarted) return;
    if (Floor3EnteredTime < 0.f) return;

    const float Elapsed = GetWorld()->GetTimeSeconds() - Floor3EnteredTime;
    if (Elapsed >= Floor3GraceSeconds)
    {
        StartHorde(EVOIDHordeTrigger::Floor3Timer);
    }
}

void AVOIDHordeManager::NotifyFloorEntered(int32 FloorIndex)
{
    if (FloorIndex == 3 && Floor3EnteredTime < 0.f)
    {
        Floor3EnteredTime = GetWorld()->GetTimeSeconds();
    }
}

void AVOIDHordeManager::AddNoiseAccumulation(float Amount)
{
    if (bHordeStarted) return;
    NoiseAccumulator += Amount;
    if (NoiseAccumulator >= HordeNoiseThreshold)
    {
        StartHorde(EVOIDHordeTrigger::NoiseAccumulated);
    }
}

void AVOIDHordeManager::NotifyPartInstalled()
{
    if (!bHordeStarted)
    {
        StartHorde(EVOIDHordeTrigger::PartInstalled);
    }
}

void AVOIDHordeManager::StartHorde(EVOIDHordeTrigger Trigger)
{
    if (bHordeStarted) return;
    bHordeStarted = true;

    UE_LOG(LogTemp, Warning, TEXT("[Horde] START — trigger=%s"),
        *UEnum::GetValueAsString(Trigger));

    CurrentWaveIndex = 0;
    SpawnWave(CurrentWaveIndex);

    GetWorld()->GetTimerManager().SetTimer(WaveTimer, [this]()
    {
        ++CurrentWaveIndex;
        if (CurrentWaveIndex < WaveCount)
        {
            SpawnWave(CurrentWaveIndex);
        }
        else
        {
            GetWorld()->GetTimerManager().ClearTimer(WaveTimer);
        }
    }, WaveInterval, true);
}

void AVOIDHordeManager::SpawnWave(int32 WaveIndex)
{
    for (const TSoftObjectPtr<AVOIDSpawnVolume>& Soft : RoofSpawnVolumes)
    {
        if (AVOIDSpawnVolume* Volume = Soft.Get())
        {
            for (int32 i = 0; i < SpawnsPerWave; ++i)
            {
                Volume->SpawnZombieOnce(); // Day 3 SpawnVolume API 가정
            }
        }
    }
}
C-2. UVOIDNoiseComponent::BroadcastNoise FloorIndex 감쇠 (추가만)
1
2
3
4
5
6
7
8
9
10
11
12
// 헤더에 추가
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Noise")
int32 OwnerFloorIndex = 1;

UFUNCTION(BlueprintCallable, Category = "Noise")
void SetFloorIndex(int32 NewIndex) { OwnerFloorIndex = NewIndex; }

// .cpp 의 BroadcastNoise 진입부에 추가
const int32 ListenerFloor = ResolveListenerFloor(); // 기존 헬퍼 가정
const int32 FloorDiff = ListenerFloor - OwnerFloorIndex;
const float Attenuation = (FloorDiff > 0) ? FMath::Pow(0.4f, FloorDiff) : 1.f;
const float EffectiveRadius = BaseRadius * NoiseScale * Attenuation;

D. ADS 조준

D-1. PlayerCharacter ADS 멤버
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 헤더 추가
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Void|ADS")
float HipFOV = 90.f;

UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Void|ADS")
float AdsFOV = 55.f;

UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Void|ADS")
float HipArmLength = 300.f;

UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Void|ADS")
float AdsArmLength = 180.f;

UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Void|ADS")
float AdsBlendSpeed = 8.f;

UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Void|ADS")
float AdsMoveMultiplier = 0.6f;

UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = "Void|ADS")
bool bIsAiming = false;

UFUNCTION(BlueprintCallable, Category = "Void|ADS")
void StartAim() { bIsAiming = true; }

UFUNCTION(BlueprintCallable, Category = "Void|ADS")
void StopAim()  { bIsAiming = false; }

protected:
    void TickAds(float DeltaTime);
D-2. PlayerCharacter .cpp ADS 보간
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include "GameFramework/SpringArmComponent.h"

void AVOIDPlayerCharacter::TickAds(float DeltaTime)
{
    if (!IsValid(FollowCamera) || !IsValid(CameraBoom)) return;

    const float TargetFOV = bIsAiming ? AdsFOV : HipFOV;
    FollowCamera->SetFieldOfView(
        FMath::FInterpTo(FollowCamera->FieldOfView, TargetFOV, DeltaTime, AdsBlendSpeed));

    const float TargetArm = bIsAiming ? AdsArmLength : HipArmLength;
    CameraBoom->TargetArmLength =
        FMath::FInterpTo(CameraBoom->TargetArmLength, TargetArm, DeltaTime, AdsBlendSpeed);

    // 무게 감속과 ADS 감속을 곱셈 합산 (단일 지점에서만 갱신해 충돌 방지)
    if (UCharacterMovementComponent* Move = GetCharacterMovement())
    {
        const float WeightRatio = Inventory ? Inventory->GetWeightRatio() : 0.f;
        const float WeightMul   = FMath::Clamp(1.f - WeightRatio * 0.6f, 0.4f, 1.f);
        const float AdsMul      = bIsAiming ? AdsMoveMultiplier : 1.f;
        Move->MaxWalkSpeed = BaseMaxWalkSpeed * WeightMul * AdsMul;
    }
}

// Tick() 에 한 줄 추가:
//   TickAds(DeltaTime);
D-3. 입력 바인딩 (SetupPlayerInputComponent 추가)
1
2
3
4
5
6
7
8
if (UEnhancedInputComponent* EIC = Cast<UEnhancedInputComponent>(PlayerInputComponent))
{
    if (IA_Aim)
    {
        EIC->BindAction(IA_Aim, ETriggerEvent::Started,   this, &AVOIDPlayerCharacter::StartAim);
        EIC->BindAction(IA_Aim, ETriggerEvent::Completed, this, &AVOIDPlayerCharacter::StopAim);
    }
}

E. (도전) 비동기 Trace AI 탐지

시간 여유 시 진행. Day 5 도전 블록 후보로도 적합.

  • 경로: Source/VoidUnreal/Public/AI/VOIDZombieAIController.h (추가만)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#pragma once

#include "CoreMinimal.h"
#include "AIController.h"
#include "WorldCollision.h"
#include "VOIDZombieAIController.generated.h"

UCLASS()
class VOIDUNREAL_API AVOIDZombieAIController : public AAIController
{
    GENERATED_BODY()

public:
    virtual void Tick(float DeltaTime) override;

protected:
    UPROPERTY(EditAnywhere, Category = "AI|AsyncSight")
    float AsyncTraceInterval = 0.2f;

    UPROPERTY(EditAnywhere, Category = "AI|AsyncSight")
    float SightRange = 2500.f;

    float LastAsyncTraceTime = -1000.f;
    FTraceDelegate AsyncTraceDelegate;
    bool bAsyncDelegateBound = false;

    void RequestAsyncSight();
    void HandleAsyncTrace(const FTraceHandle& Handle, FTraceDatum& Data);
};
  • 경로: Source/VoidUnreal/Private/AI/VOIDZombieAIController.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include "AI/VOIDZombieAIController.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "Engine/World.h"
#include "Kismet/GameplayStatics.h"

void AVOIDZombieAIController::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    if (!bAsyncDelegateBound)
    {
        AsyncTraceDelegate.BindUObject(this, &AVOIDZombieAIController::HandleAsyncTrace);
        bAsyncDelegateBound = true;
    }

    const float Now = GetWorld()->GetTimeSeconds();
    if (Now - LastAsyncTraceTime >= AsyncTraceInterval)
    {
        LastAsyncTraceTime = Now;
        RequestAsyncSight();
    }
}

void AVOIDZombieAIController::RequestAsyncSight()
{
    APawn* Self = GetPawn();
    AActor* Player = UGameplayStatics::GetPlayerPawn(this, 0);
    if (!IsValid(Self) || !IsValid(Player)) return;

    const FVector Start = Self->GetActorLocation() + FVector(0, 0, 60);
    const FVector End   = Player->GetActorLocation() + FVector(0, 0, 60);

    if ((End - Start).SizeSquared() > SightRange * SightRange) return;

    FCollisionQueryParams Params(SCENE_QUERY_STAT(VOIDAsyncSight), false, Self);
    Params.AddIgnoredActor(Player);

    GetWorld()->AsyncLineTraceByChannel(
        EAsyncTraceType::Single,
        Start, End,
        ECC_Visibility,
        Params,
        FCollisionResponseParams::DefaultResponseParam,
        &AsyncTraceDelegate);
}

void AVOIDZombieAIController::HandleAsyncTrace(const FTraceHandle& Handle, FTraceDatum& Data)
{
    if (!IsValid(this) || !IsValid(GetPawn())) return; // 좀비 Destroy 방어

    const bool bBlocked = (Data.OutHits.Num() > 0 && Data.OutHits[0].bBlockingHit);
    if (UBlackboardComponent* BB = GetBlackboardComponent())
    {
        if (!bBlocked)
        {
            BB->SetValueAsObject(TEXT("TargetActor"), UGameplayStatics::GetPlayerPawn(this, 0));
            BB->SetValueAsBool(TEXT("bHasTarget"), true);
        }
    }
}

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.