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-24 | TPS 피벗 + 7일 재조정 + Day 1 체크리스트 | Void GDD TPS 피벗·3일 지연 만회 |
| 2026-04-26 | Day 2 체크리스트 + AIPerception 옵션 2 마이그레이션 | 시야·후각 확장 대비 |
| 2026-04-27 | Day 3 체크리스트 + 무게/소음 D-2·Press E D-4 검증 | “Noise is Currency” 1차 작동 |
| 2026-04-28 | Day 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층 진입 익스트랙션”으로 재해석:
| Wave | Void 해석 | 공간 | 시간 | 핵심 메카닉 학습 |
|---|
| 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 3 | 8 kg | 이동속도 중간 페널티 |
| 차량 부품: 연료통 | Wave 3 | 12 kg | 이동속도 대 페널티 |
| 차량 부품: 점화 플러그 | Wave 3 | 2 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, CanCarryUNoiseComponent — CurrentNoise, 행동별 소음 생성(걷기/달리기/사격/픽업), 감쇠AMyGameState — CurrentScore, CurrentWaveIndex, RemainingTime, DebuffListOnScoreChanged, 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 (체력·무게·소음·웨이브·시간·디버프) / 메인·게임오버·탈출 성공 메뉴 / 입력 모드 GameOnly↔UIOnly 전환 | 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 카메라 코드 컴파일 확인)
- 에디터 실행 — 에디터에서 빌드 에러 없이 뜨는지 확인
- 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.cpp — ApplyDamage → OnHealthChanged.Broadcast(), IsDead() 시 OnDeath.Broadcast() 동작 확인VOIDNoiseComponent.cpp — EmitNoise() → CurrentNoise += SourceValue * WeightMultiplier, Tick에서 감쇠, BroadcastNoise(Radius)로 좀비 탐색VOIDBaseCharacter.cpp — ApplyDamage() → 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 종류 |
DrawDebugLine | PIE 디버그 선 | World, Start, End, Color, Persistent, Duration |
DrawDebugSphere | 충돌 지점 디버그 구 | World, Center, Radius, Segments, Color, Duration |
C. ZombieCharacter 근접 공격 cpp (예상 30분)
VOIDZombieCharacter.h에 AttackPlayer 선언 + 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++ 호출 |
|---|
TargetActor | Object: Actor | SetValueAsObject(TEXT("TargetActor"), Actor) |
TargetLocation | Vector | SetValueAsVector(TEXT("TargetLocation"), Loc) |
bHasTarget | Bool | SetValueAsBool(TEXT("bHasTarget"), true) |
LastNoiseLocation | Vector | SetValueAsVector(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) 실행
|
핵심 함수 정리
| 구현 | 핵심 함수 | 가장 헷갈릴 부분 |
|---|
| Fire | LineTraceSingleByChannel | 채널(ECC_Visibility)과 ignore actor |
| AttackPlayer | FTimerHandle + 람다 | 핸들을 헤더 멤버로 둘 것 |
| ReactToNoise | BlackboardComponent::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::BroadcastNoise — ReportNoiseEvent 호출
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 / .cpp — ReactToNoise 제거 (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” — 옵션) |
각 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 Class | BP_VOIDZombieAIController (또는 C++ AVOIDZombieAIController) |
| Auto Possess AI | Placed in World or Spawned |
| Output Log | LogPerception 카테고리 활성화 시 청각 이벤트 추적 가능 |
| Visual Logger | Window → 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 = false | Tick 함수 호출 중단 — 한 번 처리 후 자원 절약 |
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: 옥상 전체
A-5. 계단 NavLink — 좀비 층간 이동
- 각 계단 입구·출구에
NavLinkProxy 1쌍 배치 - Smart Link → Direction =
Both Ways (좀비 양방향 이동) - PIE에서
P 토글로 NavMesh 연결 확인
B. 계단 BoxTrigger — 층 자동 전환 (예상 30분)
B-1. AVOIDGameMode 상태 머신
AVOIDGameMode.h/.cpp에 추가:int32 CurrentFloorIndex = 1void 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.h — UDataAsset 상속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.h1
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.h — AActor + IVOIDVehiclePartSlot- 멤버:
RequiredType, bInstalled, InstalledMesh(시각 피드백) TryInstallPart_Implementation 흐름:Part->PartType != RequiredType → false (HUD에 “부품 종류 불일치”)Installer의 InventoryComponent에서 해당 부품 1개 차감 (RemoveItem(Part))bInstalled = true → InstalledMesh->SetVisibility(true)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에 마우스 우클릭 매핑SetupPlayerInputComponent에 Started/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()의 SpreadDegrees에 bIsAiming ? 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 압박)
| 순위 | 블록 | 이유 | 시간 |
|---|
| 1 | A. 샷건+반동 | 필수 과제 합산 마감(05-01) — 미완 시 점수 직격 | 90분 |
| 2 | B. 차량 슬롯 | Day 4 완료 기준(a) — 옥상 탈출 미완 시 게임 미완성 | 70분 |
| 3 | C. 호드 | Day 4 완료 기준(b)·”Noise is Currency” 본 검증 | 50분 |
| 4 | D. ADS | TPS 완성도 + 평가 우수성 — 시간 부족 시 Day 5 이월 가능 | 40분 |
| 5 | E. (도전) 비동기 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 시작) 전투 모듈로 그대로 이식 —
UVOIDWeaponConfig → UTeamWeaponConfig 리네임만 - 차량 슬롯 인터페이스 패턴: 팀플의 처형/상호작용 슬롯 시스템 베이스로 재활용
- 호드 트리거 로직: 팀플 웨이브 로그라이트의 “압도 페이즈” 베이스
- 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 0 | 8번 과제 + 샷건+반동 합산 마감 |
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);
|
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);
}
}
}
|