<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>hyunjunstar 님의 블로그</title>
    <link>https://hyunjunstar.tistory.com/</link>
    <description>hyunjunstar 님의 블로그 입니다.</description>
    <language>ko</language>
    <pubDate>Fri, 26 Jun 2026 21:50:18 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>hyunjunstar</managingEditor>
    <item>
      <title>Unreal C++ 싱글플레이 FPS 슈터 게임 팀 프로젝트 적 AI - 최종</title>
      <link>https://hyunjunstar.tistory.com/103</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://hyunjunstar.tistory.com/101&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://hyunjunstar.tistory.com/101&lt;/a&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;위 링크에서는 적 AI의 TargetActor 갱신 문제와 공격 데미지 타이밍 문제를 수정했다. &lt;br /&gt;&lt;br /&gt;TargetActor가&amp;nbsp;한&amp;nbsp;번만&amp;nbsp;설정되던&amp;nbsp;문제는&amp;nbsp;SetTimer()를&amp;nbsp;반복&amp;nbsp;실행하도록&amp;nbsp;변경하여&amp;nbsp;해결했고,&amp;nbsp;&amp;nbsp; &lt;br /&gt;BTTask_AttackTarget에서&amp;nbsp;ApplyDamage()를&amp;nbsp;즉시&amp;nbsp;호출하던&amp;nbsp;구조는&amp;nbsp;Anim&amp;nbsp;Notify&amp;nbsp;시점에&amp;nbsp;데미지를&amp;nbsp;적용하는&amp;nbsp;방식으로&amp;nbsp;수정했다. &lt;br /&gt;&lt;br /&gt;또한&amp;nbsp;Montage,&amp;nbsp;Anim&amp;nbsp;Notify,&amp;nbsp;Anim&amp;nbsp;Graph를&amp;nbsp;활용해&amp;nbsp;공격,&amp;nbsp;사망,&amp;nbsp;이동&amp;nbsp;애니메이션이&amp;nbsp;더&amp;nbsp;자연스럽게&amp;nbsp;연결되도록&amp;nbsp;개선했다. &lt;br /&gt;&lt;br /&gt;이번 글에서는 최종적으로 완성된 적 AI의 전체 흐름과 코드를 정리하려고 한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;적 AI 전체 흐름&lt;/h2&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;1. 전체 통합 구조 &lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/l1Fhj/dJMcadvkTLx/K2gKMbBaQGbY6B5qGkwUd0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/l1Fhj/dJMcadvkTLx/K2gKMbBaQGbY6B5qGkwUd0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/l1Fhj/dJMcadvkTLx/K2gKMbBaQGbY6B5qGkwUd0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fl1Fhj%2FdJMcadvkTLx%2FK2gKMbBaQGbY6B5qGkwUd0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;1080&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;적 AI는 EnemySpawner에서 생성된 뒤 EnemyCharacter, EnemyAIController, Blackboard, Behavior Tree를 거쳐 추적과 공격을 수행합니다.&amp;nbsp;&amp;nbsp; &lt;br /&gt;실제 데미지는 공격 Task에서 바로 적용하지 않고, 공격 Montage의 Anim Notify 시점에 ApplyAttackDamage()를 호출해 처리합니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;2. 스폰 흐름&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qIPjX/dJMcagTdVet/1h1THP5MpmZXBUTmR8Pskk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qIPjX/dJMcagTdVet/1h1THP5MpmZXBUTmR8Pskk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qIPjX/dJMcagTdVet/1h1THP5MpmZXBUTmR8Pskk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqIPjX%2FdJMcagTdVet%2F1h1THP5MpmZXBUTmR8Pskk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;1080&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;EnemySpawner는 게임 시작 시 NavMesh 위의 랜덤 위치를 찾아 적을 생성합니다.&amp;nbsp;&amp;nbsp; &lt;br /&gt;스폰 위치는 SpawnRadius 안에서 탐색하며, 충돌이 발생하면 가능한 위치로 조정하거나 스폰을 건너뛰도록 처리했습니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;3. EnemyCharacter 초기 설정&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cclAJD/dJMb997Ccsb/VyanJmlt9UhrXTz16871DK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cclAJD/dJMb997Ccsb/VyanJmlt9UhrXTz16871DK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cclAJD/dJMb997Ccsb/VyanJmlt9UhrXTz16871DK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcclAJD%2FdJMb997Ccsb%2FVyanJmlt9UhrXTz16871DK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;1080&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;EnemyCharacter는&amp;nbsp;HP,&amp;nbsp;방어력,&amp;nbsp;공격력,&amp;nbsp;탐지&amp;nbsp;범위,&amp;nbsp;공격&amp;nbsp;범위&amp;nbsp;등&amp;nbsp;적의&amp;nbsp;기본&amp;nbsp;전투&amp;nbsp;데이터를&amp;nbsp;관리합니다.&amp;nbsp;&amp;nbsp; &lt;br /&gt;또한 Behavior Tree 참조, RVO 회피, 이동 방향 회전, 공격/사망 애니메이션 이벤트를 담당합니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;4. 플레이어 탐지와 Blackboard 갱신&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oplqz/dJMcadozy7J/a8JF5v8YsMaFTq5uWO1n91/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oplqz/dJMcadozy7J/a8JF5v8YsMaFTq5uWO1n91/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oplqz/dJMcadozy7J/a8JF5v8YsMaFTq5uWO1n91/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Foplqz%2FdJMcadozy7J%2Fa8JF5v8YsMaFTq5uWO1n91%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;1080&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;EnemyAIController는 적을 Possess한 뒤 Behavior Tree를 실행하고, 0.2초마다 플레이어를 탐지합니다.&amp;nbsp;&amp;nbsp; &lt;br /&gt;플레이어가 DetectionRange 안에 있으면 Blackboard의 TargetActor에 저장하고, 범위 밖이면 TargetActor를 제거합니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;5. Behavior Tree 판단 구조&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cleBfP/dJMcaiwIrVl/cTuK2ImLra59n284fTjuL1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cleBfP/dJMcaiwIrVl/cTuK2ImLra59n284fTjuL1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cleBfP/dJMcaiwIrVl/cTuK2ImLra59n284fTjuL1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcleBfP%2FdJMcaiwIrVl%2FcTuK2ImLra59n284fTjuL1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;1080&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Behavior&amp;nbsp;Tree는&amp;nbsp;Blackboard의&amp;nbsp;TargetActor를&amp;nbsp;기준으로&amp;nbsp;현재&amp;nbsp;행동을&amp;nbsp;결정합니다.&amp;nbsp;&amp;nbsp; &lt;br /&gt;TargetActor가&amp;nbsp;있고&amp;nbsp;공격&amp;nbsp;범위&amp;nbsp;안이면&amp;nbsp;공격&amp;nbsp;Sequence를&amp;nbsp;실행하고,&amp;nbsp;공격&amp;nbsp;범위&amp;nbsp;밖이면&amp;nbsp;Move&amp;nbsp;To로&amp;nbsp;플레이어를&amp;nbsp;추적합니다.&amp;nbsp;&amp;nbsp; &lt;br /&gt;TargetActor가 없으면 짧게 대기합니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;6. 공격 범위 검사&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/yoOkz/dJMcaics4th/JhzVLRFzqSGscWDFPyQ26k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/yoOkz/dJMcaics4th/JhzVLRFzqSGscWDFPyQ26k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/yoOkz/dJMcaics4th/JhzVLRFzqSGscWDFPyQ26k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FyoOkz%2FdJMcaics4th%2FJhzVLRFzqSGscWDFPyQ26k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;1080&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;BTDecorator_IsInAttackRange는&amp;nbsp;TargetActor와&amp;nbsp;적&amp;nbsp;사이의&amp;nbsp;거리를&amp;nbsp;계산해&amp;nbsp;공격&amp;nbsp;가능&amp;nbsp;여부를&amp;nbsp;판단합니다.&amp;nbsp;&amp;nbsp; &lt;br /&gt;공격 범위 안이면 공격 Task가 실행되고, 범위 밖이면 추적 로직으로 넘어갑니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;7. 공격 Task 실행&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c1p6nX/dJMb99T4HOb/E7teSpgRitlllpjRlIn3fK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c1p6nX/dJMb99T4HOb/E7teSpgRitlllpjRlIn3fK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c1p6nX/dJMb99T4HOb/E7teSpgRitlllpjRlIn3fK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc1p6nX%2FdJMb99T4HOb%2FE7teSpgRitlllpjRlIn3fK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;1080&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;BTTask_AttackTarget은&amp;nbsp;공격이&amp;nbsp;가능한&amp;nbsp;상태인지&amp;nbsp;확인한&amp;nbsp;뒤,&amp;nbsp;적의&amp;nbsp;이동을&amp;nbsp;멈추고&amp;nbsp;플레이어&amp;nbsp;방향으로&amp;nbsp;회전시킵니다.&amp;nbsp;&amp;nbsp; &lt;br /&gt;이후 PlayAttackAnimation()을 호출해 공격 Montage를 재생하고, MarkAttack()으로 공격 쿨타임을 기록합니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;8. Montage와 Anim Notify&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bWEhKe/dJMb99T4HOq/WkrnuQLG4h7TuLRLj0JqU1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bWEhKe/dJMb99T4HOq/WkrnuQLG4h7TuLRLj0JqU1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bWEhKe/dJMb99T4HOq/WkrnuQLG4h7TuLRLj0JqU1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbWEhKe%2FdJMb99T4HOq%2FWkrnuQLG4h7TuLRLj0JqU1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;1080&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;BP_EnemyCharacter에서는 C++에서 호출한 PlayAttackAnimation 이벤트를 받아 공격 Montage를 재생합니다.&amp;nbsp;&amp;nbsp; &lt;br /&gt;공격 Montage 안에는 AttackHit Notify가 배치되어 있으며, 실제 타격 프레임에 데미지 함수가 호출되도록 연결했습니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;9. Animation Blueprint 처리&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TVZqK/dJMb99T4HPw/hqTFGzZXaSbCenb26gsFt0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TVZqK/dJMb99T4HPw/hqTFGzZXaSbCenb26gsFt0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TVZqK/dJMb99T4HPw/hqTFGzZXaSbCenb26gsFt0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTVZqK%2FdJMb99T4HPw%2FhqTFGzZXaSbCenb26gsFt0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;1080&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;ABP_ZombieEnemy는&amp;nbsp;적의&amp;nbsp;속도를&amp;nbsp;계산해&amp;nbsp;Idle&amp;nbsp;/&amp;nbsp;Walk&amp;nbsp;/&amp;nbsp;Run&amp;nbsp;애니메이션을&amp;nbsp;전환합니다.&amp;nbsp;&amp;nbsp; &lt;br /&gt;또한 DefaultSlot을 통해 이동 애니메이션 위에 공격 및 사망 Montage가 재생될 수 있도록 구성했습니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;10. 에디터 에셋 연결&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dcpo3W/dJMcafUkeLe/UEeMXb1HN33NyXiVyJ4vdK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dcpo3W/dJMcafUkeLe/UEeMXb1HN33NyXiVyJ4vdK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dcpo3W/dJMcafUkeLe/UEeMXb1HN33NyXiVyJ4vdK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdcpo3W%2FdJMcafUkeLe%2FUEeMXb1HN33NyXiVyJ4vdK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;1080&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종적으로&amp;nbsp;BP_EnemyCharacter,&amp;nbsp;ABP_ZombieEnemy,&amp;nbsp;Blend&amp;nbsp;Space,&amp;nbsp;Attack&amp;nbsp;Montage,&amp;nbsp;Death&amp;nbsp;Montage를&amp;nbsp;연결해&amp;nbsp;실제&amp;nbsp;게임에서&amp;nbsp;AI&amp;nbsp;캐릭터가&amp;nbsp;자연스럽게&amp;nbsp;움직이고&amp;nbsp;공격하도록&amp;nbsp;구성했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;핵심 코드&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. EnemyAIController - 0.2초마다 TargetActor 갱신&lt;/p&gt;
&lt;pre id=&quot;code_1779975909387&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// EnemyAIController.cpp

// 플레이어 감지 상태를 주기적으로 갱신
GetWorldTimerManager().SetTimer(
    FindPlayerTimerHandle,
    this,
    &amp;amp;AEnemyAIController::SetTargetPlayer,
    0.2f,
    true
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. EnemyAIController - 감지 범위에 따라 TargetActor 설정/제거&lt;/p&gt;
&lt;pre id=&quot;code_1779975923132&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// EnemyAIController.cpp

// 적과 플레이어 사이의 거리를 계산
const float DistanceToPlayer = FVector::Dist2D(
    ControlledEnemy-&amp;gt;GetActorLocation(),
    TargetPlayer-&amp;gt;GetActorLocation()
);

// 감지 범위 안이면 플레이어를 TargetActor로 등록하고, 벗어나면 제거
if (DistanceToPlayer &amp;lt;= ControlledEnemy-&amp;gt;DetectionRange)
{
    BB-&amp;gt;SetValueAsObject(TEXT(&quot;TargetActor&quot;), TargetPlayer);
}
else
{
    BB-&amp;gt;ClearValue(TEXT(&quot;TargetActor&quot;));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. BTDecorator_IsInAttackRange - 공격 범위 판단&lt;/p&gt;
&lt;pre id=&quot;code_1779975956674&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// BTDecorator_IsInAttackRange.cpp

// 적과 타겟 사이의 거리를 계산
const float Distance = FVector::Dist2D(
    Enemy-&amp;gt;GetActorLocation(),
    TargetActor-&amp;gt;GetActorLocation()
);

// 공격 범위 안이면 true, 아니면 false
return Distance &amp;lt;= Enemy-&amp;gt;AttackRange;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. BTTask_AttackTarget - 이동 정지, 회전, 공격 몽타주 실행&lt;/p&gt;
&lt;pre id=&quot;code_1779975972984&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// BTTask_AttackTarget.cpp

// 공격 시작 전에 이동과 AI Focus를 정리
AIController-&amp;gt;StopMovement();
AIController-&amp;gt;ClearFocus(EAIFocusPriority::Gameplay);

// 이전 이동 입력이 남아 공격 방향을 방해하지 않도록 즉시 정지
Enemy-&amp;gt;GetCharacterMovement()-&amp;gt;StopMovementImmediately();

// 플레이어 방향으로 몸 돌리기
FVector Direction = TargetActor-&amp;gt;GetActorLocation() - Enemy-&amp;gt;GetActorLocation();
Direction.Z = 0.f;

if (!Direction.IsNearlyZero())
{
    // Z축을 제외한 방향으로 회전하여 적이 플레이어를 바라보게 함
    FRotator LookRotation = Direction.Rotation();

    Enemy-&amp;gt;SetActorRotation(LookRotation);
    AIController-&amp;gt;SetControlRotation(LookRotation);
}

// 공격 애니메이션 재생
Enemy-&amp;gt;PlayAttackAnimation();

// 공격을 시작한 시점에 쿨타임 기록
Enemy-&amp;gt;MarkAttack();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. EnemyCharacter - Anim Notify 시점 실제 데미지 적용&lt;/p&gt;
&lt;pre id=&quot;code_1779975996675&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// EnemyCharacter.cpp

// Notify가 늦게 호출되는 동안 플레이어가 멀리 도망간 경우 데미지를 주지 않음
const float DistanceToTarget = FVector::Dist2D(
    GetActorLocation(),
    TargetActor-&amp;gt;GetActorLocation()
);

if (DistanceToTarget &amp;gt; AttackRange + 50.f)
{
    return;
}

// TargetActor는 플레이어이므로 플레이어의 TakeDamage가 호출됨
UGameplayStatics::ApplyDamage(
    TargetActor,
    AttackDamage,
    AIController,
    this,
    nullptr
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6. &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;EnemyCharacter -&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; 사망 처리&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1779976134346&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// EnemyCharacter.cpp

// 사망 후 이동과 충돌을 막아 AI가 더 이상 움직이거나 막히지 않게 함
GetCharacterMovement()-&amp;gt;DisableMovement();
GetCharacterMovement()-&amp;gt;StopMovementImmediately();
GetCapsuleComponent()-&amp;gt;SetCollisionEnabled(ECollisionEnabled::NoCollision);

// BP_EnemyCharacter에서 연결한 사망 몽타주 재생
PlayDeathAnimation();

// 사망 애니메이션이 보일 시간을 준 뒤 액터 제거
SetLifeSpan(2.5f);&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;프로젝트 개인 회고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번&amp;nbsp;적&amp;nbsp;AI&amp;nbsp;구현은&amp;nbsp;수업에서&amp;nbsp;아직&amp;nbsp;자세히&amp;nbsp;다루지&amp;nbsp;않은&amp;nbsp;내용이라&amp;nbsp;처음에는&amp;nbsp;구조를&amp;nbsp;이해하는&amp;nbsp;데&amp;nbsp;어려움이&amp;nbsp;있었다.&amp;nbsp;&amp;nbsp; &lt;br /&gt;그래서&amp;nbsp;Unreal&amp;nbsp;Engine의&amp;nbsp;AIController,&amp;nbsp;Blackboard,&amp;nbsp;Behavior&amp;nbsp;Tree,&amp;nbsp;Anim&amp;nbsp;Notify&amp;nbsp;관련&amp;nbsp;자료와&amp;nbsp;영상을&amp;nbsp;참고하면서&amp;nbsp;전체&amp;nbsp;흐름을&amp;nbsp;먼저&amp;nbsp;공부했다. &lt;br /&gt;&lt;br /&gt;처음에는 적이 플레이어를 따라오고 공격하는 기능이 단순할 것이라고 생각했는데,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 진행해보니 플레이어 탐지, TargetActor 갱신, 추적 조건, 공격 범위 판단, 공격 애니메이션, 데미지 적용 타이밍, 사망 처리까지 여러 요소가 연결되어야 자연스럽게 동작한다는 것을 알게 되었다. &lt;br /&gt;&lt;br /&gt;특히&amp;nbsp;BTTask_AttackTarget에서&amp;nbsp;바로&amp;nbsp;데미지를&amp;nbsp;적용하는&amp;nbsp;방식과&amp;nbsp;Anim&amp;nbsp;Notify&amp;nbsp;시점에&amp;nbsp;데미지를&amp;nbsp;적용하는&amp;nbsp;방식의&amp;nbsp;차이를&amp;nbsp;보면서,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임&amp;nbsp;개발에서는&amp;nbsp;코드가&amp;nbsp;실행되는&amp;nbsp;시점뿐만&amp;nbsp;아니라&amp;nbsp;플레이어가&amp;nbsp;체감하는&amp;nbsp;애니메이션&amp;nbsp;타이밍도&amp;nbsp;중요하다는&amp;nbsp;점을&amp;nbsp;배웠다. &lt;br /&gt;&lt;br /&gt;또한 EnemyAIController, Blackboard, Behavior Tree, EnemyCharacter가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각각 어떤 역할을 맡는지 정리하면서 Unreal Engine의 AI 구조를 더 명확하게 이해할 수 있었다.&amp;nbsp;&amp;nbsp; &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번&amp;nbsp;구현을&amp;nbsp;통해&amp;nbsp;아직&amp;nbsp;익숙하지&amp;nbsp;않은&amp;nbsp;기능도&amp;nbsp;자료를&amp;nbsp;찾아보고&amp;nbsp;흐름을&amp;nbsp;분석하면&amp;nbsp;프로젝트에&amp;nbsp;적용할&amp;nbsp;수&amp;nbsp;있다는&amp;nbsp;자신감을&amp;nbsp;얻었다. &lt;br /&gt;&lt;br /&gt;다음 프로젝트 부터는 이 구조를 더 공부해서 여러 공격 패턴, 피격 애니메이션, 난이도별 AI 반응 속도 같은 기능도 직접 확장해보고 싶다.&lt;/p&gt;</description>
      <category>Unreal C++</category>
      <author>hyunjunstar</author>
      <guid isPermaLink="true">https://hyunjunstar.tistory.com/103</guid>
      <comments>https://hyunjunstar.tistory.com/103#entry103comment</comments>
      <pubDate>Thu, 28 May 2026 23:03:02 +0900</pubDate>
    </item>
    <item>
      <title>Unreal C++ 싱글플레이 FPS 슈터 게임 팀 프로젝트 구조 정리 및 트러블슈팅</title>
      <link>https://hyunjunstar.tistory.com/102</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;적 AI 구현&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EnemySpawner&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;- NavMesh 위에 적 랜덤 생성 &lt;br /&gt;&lt;br /&gt;&amp;nbsp;EnemyAIController &lt;br /&gt;&amp;nbsp;- 플레이어를 0.2초마다 탐지 &lt;br /&gt;&amp;nbsp;- 감지되면 Blackboard.TargetActor에 저장 &lt;br /&gt;&lt;br /&gt;Behavior Tree &lt;br /&gt;&amp;nbsp;- TargetActor가 있으면 추적 &lt;br /&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;-&lt;span&gt; &lt;/span&gt;&lt;/span&gt;공격 범위 안이면 공격 Task 실행&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BTDecorator_IsInAttackRange &lt;br /&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;-&lt;span&gt; &lt;/span&gt;&lt;/span&gt;플레이어가 공격 범위 안인지 검사 &lt;br /&gt;&lt;br /&gt;BTTask_AttackTarget &lt;br /&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;-&lt;span&gt; &lt;/span&gt;&lt;/span&gt;이동 정지 &lt;br /&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;-&lt;span&gt; &lt;/span&gt;&lt;/span&gt;플레이어 방향으로 회전 &lt;br /&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;-&lt;span&gt; &lt;/span&gt;&lt;/span&gt;공격 애니메이션 실행 &lt;br /&gt;&lt;br /&gt;EnemyCharacter &lt;br /&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;-&lt;span&gt; &lt;/span&gt;&lt;/span&gt;HP, 공격력, 탐지/공격 범위 관리 &lt;br /&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;-&lt;span&gt; &lt;/span&gt;&lt;/span&gt;Anim Notify 시점에 실제 데미지 적용&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;-&lt;span&gt; &lt;/span&gt;&lt;/span&gt;사망 시 점수 지급, 이동/충돌 비활성화&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 적 캐릭터 구현&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1-1. 체력 / 방어력 시스템 구현&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; - EnemyCharacter에서 MaxHP, CurrentHP, Defense 관리 &lt;br /&gt;&amp;nbsp; &amp;nbsp; - 피격 시 방어력을 반영해 최종 데미지 계산&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1-2. 피격 / 사망 처리&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; - TakeDamage()로 데미지 수신 &lt;br /&gt;&amp;nbsp; &amp;nbsp; - HP가 0 이하가 되면 Die() 실행 &lt;br /&gt;&amp;nbsp; &amp;nbsp; - 사망 시 점수 지급, 이동 비활성화, 충돌 제거, 사망 애니메이션 재생&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 플레이어 탐지&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;- EnemyAIController에서 플레이어를 0.2초마다 탐지 &lt;br /&gt;&amp;nbsp;- 적과 플레이어 사이의 거리를 계산 &lt;br /&gt;&amp;nbsp;- DetectionRange 안에 있으면 Blackboard의 TargetActor에 플레이어 저장 &lt;br /&gt;&amp;nbsp;- 범위 밖이면 TargetActor 제거&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 플레이어 추적&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;- Behavior Tree에서 TargetActor가 존재하면 추적 로직 실행 &lt;br /&gt;&amp;nbsp;- Blackboard에 저장된 플레이어를 기준으로 이동 &lt;br /&gt;&amp;nbsp;- NavMesh를 사용해 이동 가능한 경로를 따라 추적&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 공격 범위 내 공격&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;- BTDecorator_IsInAttackRange에서 플레이어가 공격 범위 안에 있는지 검사 &lt;br /&gt;&amp;nbsp;- 범위 안이면 BTTask_AttackTarget 실행 &lt;br /&gt;&amp;nbsp;- 공격 Task에서 이동 정지 &lt;br /&gt;&amp;nbsp;- 플레이어 방향으로 회전 &lt;br /&gt;&amp;nbsp;- 공격 애니메이션 실행 &lt;br /&gt;&amp;nbsp;- 실제 데미지는 애니메이션 Notify 시점에 ApplyAttackDamage()로 적용&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. NavMesh 기반 경로 탐색&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;- EnemySpawner가 NavMesh 위의 랜덤 위치를 찾아 적 생성 &lt;br /&gt;&amp;nbsp;- AI 이동도 NavMesh 기반으로 처리 &lt;br /&gt;&amp;nbsp;- 벽이나 이동 불가능한 영역을 피해 플레이어에게 접근&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 장애물 회피&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;- EnemyCharacter의 CharacterMovement에 RVO Avoidance 적용 &lt;br /&gt;&amp;nbsp;- 여러 적이 동시에 이동할 때 서로 겹치는 현상 완화 &lt;br /&gt;&amp;nbsp;- 회피 반경을 설정해 적들이 자연스럽게 퍼져 이동하도록 처리&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;추가 리스트&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 적 AI EnemySpawner&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;- 스폰할 적 클래스 EnemyClass 지정 &lt;br /&gt;&amp;nbsp;- 스폰 개수 EnemySpawnCount 설정 &lt;br /&gt;&amp;nbsp;- 스폰 반경 SpawnRadius 설정 &lt;br /&gt;&amp;nbsp;- 충돌 시 스폰 실패 처리 &lt;br /&gt;&amp;nbsp;- 무한 반복 방지를 위한 최대 스폰 시도 횟수 적용&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 스킬 공격 / Grenade 추가 리스트&lt;/h3&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;스킬공격 구현&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;CombatComponent.FireGrenade()&lt;/b&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;-&lt;span&gt;&amp;nbsp;스킬&lt;/span&gt;&lt;/span&gt;&amp;nbsp;사용 가능 여부 확인&lt;br /&gt;&amp;nbsp;-&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;손 소켓(hand_rSocket) 위치 계산&lt;br /&gt;&amp;nbsp;-&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;카메라 기준 조준 방향 보정&lt;br /&gt;&amp;nbsp;-&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;GrenadeProjectile 생성&lt;br /&gt;&amp;nbsp;-&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;데미지 / 폭발 반경 전달&lt;/p&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;GrenadeProjectile 코드 구조&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;CollisionComp&lt;br /&gt;&amp;nbsp;- 충돌 판정용 SphereComponent&lt;br /&gt;&lt;br /&gt;MeshComp&lt;br /&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;-&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;외형 표시용 StaticMeshComponent&lt;br /&gt;&lt;br /&gt;ProjectileMovement&lt;br /&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;-&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;속도, 중력, 바운스, 마찰 적용&lt;br /&gt;&lt;br /&gt;FuseTimer&lt;br /&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;-&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;일정 시간 후 Explode 실행&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Explode()&lt;br /&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;-&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;현재 위치에 폭발 이펙트 생성&lt;br /&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;-&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;폭발 사운드 재생&lt;br /&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;-&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;ApplyRadialDamage로 범위 데미지 적용&lt;br /&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;-&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;투사체 Destroy&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;트러블슈팅&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;적 AI&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 1. 공격 애니메이션과 데미지 타이밍이 안 맞음&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779804700106&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ========== 기존 코드 ========== // 

// BTTask_AttackTarget.cpp

UGameplayStatics::ApplyDamage(
    TargetActor,
    Enemy-&amp;gt;AttackDamage,
    AIController,
    Enemy,
    nullptr
    );&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1779805371547&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ========== 수정 후 코드 ========== // 

// BTTask_AttackTarget.cpp

Enemy-&amp;gt;PlayAttackAnimation();
Enemy-&amp;gt;MarkAttack();

// EnemyCharacter.h

UFUNCTION(BlueprintCallable, Category = &quot;Enemy|Attack&quot;)
void ApplyAttackDamage();

// EnemyCharacter.cpp

void AEnemyCharacter::ApplyAttackDamage()
{
    if (bIsDead)
    {
        return;
    }
    AAIController* AIController = Cast&amp;lt;AAIController&amp;gt;(GetController());
    if (!AIController)
    {
        return;
    }
    UBlackboardComponent* BlackboardComp = AIController-&amp;gt;GetBlackboardComponent();
    if (!BlackboardComp)
    {
        return;
    }

    AActor* TargetActor = Cast&amp;lt;AActor&amp;gt;(
        BlackboardComp-&amp;gt;GetValueAsObject(TEXT(&quot;TargetActor&quot;))
    );

    if (!TargetActor)
    {
        return;
    }

    UGameplayStatics::ApplyDamage(
        TargetActor,
        AttackDamage,
        AIController,
        this,
        nullptr
        );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;- BTTask에서 바로 데미지를 주지 않고, Anim Notify에서 ApplyAttackDamage 호출로 해결&lt;br /&gt;&lt;br /&gt;&lt;b&gt;문제 2. 적이 죽은 뒤에도 움직이거나 공격하려고 함&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779805032989&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ========== 기존 코드 ========== // 

// EnemyCharacter.cpp

void AEnemyCharacter::Die()
{
    if (bIsDead)
    {
        return;
    }
    bIsDead = true;
    AShooterGameMode* GM = Cast&amp;lt;AShooterGameMode&amp;gt;(UGameplayStatics::GetGameMode(this));
     
    if (GM)
    {
        GM-&amp;gt;AddScore(ScoreValue);
    }
    Destroy();
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1779805478130&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ========== 수정후 코드 ========== // 

// BTTask_AttackTarget.cpp

AEnemyCharacter* Enemy = Cast&amp;lt;AEnemyCharacter&amp;gt;(AIController-&amp;gt;GetPawn());
if (!Enemy || Enemy-&amp;gt;IsDead())
{
    return EBTNodeResult::Failed;
}

// BTDecorator_IsInAttackRange.cpp

const AEnemyCharacter* Enemy = Cast&amp;lt;AEnemyCharacter&amp;gt;(AIController-&amp;gt;GetPawn());
if (!Enemy || Enemy-&amp;gt;IsDead())
{
    return false;
}

// EnemyCharacter.cpp

void AEnemyCharacter::Die()
{
    if (bIsDead)
    {
        return;
    }
    
    bIsDead = true;

    AShooterGameMode* GM = Cast&amp;lt;AShooterGameMode&amp;gt;(UGameplayStatics::GetGameMode(this));
    if (GM)
    {
        GM-&amp;gt;AddScore(ScoreValue);
    }

    GetCharacterMovement()-&amp;gt;DisableMovement();
    GetCharacterMovement()-&amp;gt;StopMovementImmediately();
    GetCapsuleComponent()-&amp;gt;SetCollisionEnabled(ECollisionEnabled::NoCollision);

    PlayDeathAnimation();

    SetLifeSpan(2.5f);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;- IsDead 체크 추가, 사망 시 Movement/Collision 비활성화로 해결 &lt;br /&gt;&lt;br /&gt;&lt;b&gt;문제 3. 플레이어가 공격 범위 밖으로 벗어나도 맞는 문제&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779805165053&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ========== 기존 코드 ========== // 

// BTDecorator_IsInAttackRange.cpp

const float Distance = FVector::Dist2D(
    Enemy-&amp;gt;GetActorLocation(),
    TargetActor-&amp;gt;GetActorLocation()
    );

return Distance &amp;lt;= Enemy-&amp;gt;AttackRange;&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1779805572065&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ========== 수정후 코드 ========== // 
  
// EnemyCharacter.cpp
void AEnemyCharacter::ApplyAttackDamage()
{
    if (bIsDead)
    {
        return;
    }

    AAIController* AIController = Cast&amp;lt;AAIController&amp;gt;(GetController());
    if (!AIController)
    {
        return;
    }

    UBlackboardComponent* BlackboardComp = AIController-&amp;gt;GetBlackboardComponent();
    if (!BlackboardComp)
    {
        return;
    }

    AActor* TargetActor = Cast&amp;lt;AActor&amp;gt;(
        BlackboardComp-&amp;gt;GetValueAsObject(TEXT(&quot;TargetActor&quot;))
        );

    if (!TargetActor)
    {
        return;
    }

    const float DistanceToTarget = FVector::Dist2D(
        GetActorLocation(),
        TargetActor-&amp;gt;GetActorLocation()
        );

    if (DistanceToTarget &amp;gt; AttackRange + 50.f)
    {
        return;
    }

    UGameplayStatics::ApplyDamage(
        TargetActor,
        AttackDamage,
        AIController,
        this,
        nullptr
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;- Notify 시점에 AttackRange + 보정값으로 거리 재검사로 해결 &lt;br /&gt;&lt;br /&gt;&lt;b&gt;문제 4. 여러 적이 겹쳐서 이동이 어색함&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779805718618&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ========== 기존 코드 ========== // 

// EnemyCharacter.cpp

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

    MaxHP = 100.f;
    CurrentHP = MaxHP;
    Defense = 0.f;
    ScoreValue = 1;
    AttackDamage = 10.f;
    DetectionRange = 1200.f;
    AttackRange = 150.f;
    AttackCooldown = 1.5f;
    bIsDead = false;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1779805724114&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ========== 수정후 코드 ========== // 

// EnemyCharacter.cpp

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

    MaxHP = 100.f;
    CurrentHP = MaxHP;
    Defense = 0.f;
    ScoreValue = 1;
    AttackDamage = 10.f;
    DetectionRange = 1200.f;
    AttackRange = 150.f;
    AttackCooldown = 1.5f;
    LastAttackTime = -AttackCooldown;
    bIsDead = false;

    GetCharacterMovement()-&amp;gt;bUseRVOAvoidance = true;
    GetCharacterMovement()-&amp;gt;AvoidanceConsiderationRadius = 300.f;

    bUseControllerRotationYaw = false;
    GetCharacterMovement()-&amp;gt;bOrientRotationToMovement = true;
    GetCharacterMovement()-&amp;gt;RotationRate = FRotator(0.f, 720.f, 0.f);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;- CharacterMovement에 RVO Avoidance 적용으로 해결&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;스킬 공격&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 1. 투사체로&amp;nbsp;변경&amp;nbsp;후&amp;nbsp;발사하자마자&amp;nbsp;플레이어와&amp;nbsp;충돌하는&amp;nbsp;문제&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779805791669&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ========== 기존 코드 ========== // 

// CombatComponent.cpp

FVector ExplodeLocation = BaseStart + (ShootDirection * 400.f);

TArray&amp;lt;AActor*&amp;gt; IgnoreActors;
IgnoreActors.Add(Owner);

UGameplayStatics::ApplyRadialDamage(
    GetWorld(),
    GrenadeDamage,
    ExplodeLocation,
    GrenadeRadius,
    UDamageType::StaticClass(),
    IgnoreActors,
    Owner,
    Owner-&amp;gt;GetInstigatorController(),
    true
    );&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1779805836214&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ========== 수정후 코드 ========== // 

// CombatComponent.cpp

FActorSpawnParameters SpawnParams;
SpawnParams.Owner = Owner;
SpawnParams.Instigator = Cast&amp;lt;APawn&amp;gt;(Owner);
SpawnParams.SpawnCollisionHandlingOverride =
    ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn;

AGrenadeProjectile* Grenade = GetWorld()-&amp;gt;SpawnActor&amp;lt;AGrenadeProjectile&amp;gt;(
    GrenadeProjectileClass,
    SpawnLocation,
    ShootDirection.Rotation(),
    SpawnParams
    );
    
// GrenadeProjectile.cpp

void AGrenadeProjectile::BeginPlay()
{
    Super::BeginPlay();

    if (AActor* OwnerActor = GetOwner())
    {
        CollisionComp-&amp;gt;IgnoreActorWhenMoving(OwnerActor, true);
    }

    GetWorldTimerManager().SetTimer(
        FuseTimerHandle,
        this,
        &amp;amp;AGrenadeProjectile::Explode,
        FuseTime,
        false
        );
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;- GrenadeProjectile BeginPlay에서 OwnerActor를 충돌 무시 처리로 해결&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt; 문제 2. 조준 방향과 실제 발사 방향이 어긋남&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779806475303&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ========== 기존 코드 ========== // 

// CombatComponent.cpp

FVector ShootDirection = Owner-&amp;gt;GetActorForwardVector();
FVector BaseStart = Owner-&amp;gt;GetActorLocation();

if (CharOwner)
{
    if (APlayerController* PC = Cast&amp;lt;APlayerController&amp;gt;(CharOwner-&amp;gt;GetController()))
    {
        FVector CameraLoc;
        FRotator CameraRot;
        PC-&amp;gt;GetPlayerViewPoint(CameraLoc, CameraRot);

        ShootDirection = CameraRot.Vector();
        BaseStart = CameraLoc;
    }
}

FVector ExplodeLocation = BaseStart + (ShootDirection * 400.f);&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1779806599796&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ========== 수정후 코드 ========== // 

// CombatComponent.cpp

ACharacter* CharOwner = Cast&amp;lt;ACharacter&amp;gt;(Owner);

FVector SpawnLocation = Owner-&amp;gt;GetActorLocation();
FVector ShootDirection = Owner-&amp;gt;GetActorForwardVector();

if (CharOwner)
{
    if (CharOwner-&amp;gt;GetMesh()-&amp;gt;DoesSocketExist(TEXT(&quot;hand_rSocket&quot;)))
    {
        SpawnLocation = CharOwner-&amp;gt;GetMesh()-&amp;gt;GetSocketLocation(TEXT(&quot;hand_rSocket&quot;));
    }

    if (APlayerController* PC = Cast&amp;lt;APlayerController&amp;gt;(CharOwner-&amp;gt;GetController()))
    {
        FVector CameraLoc;
        FRotator CameraRot;
        PC-&amp;gt;GetPlayerViewPoint(CameraLoc, CameraRot);

        FVector TraceStart = CameraLoc;
        FVector TraceEnd = TraceStart + CameraRot.Vector() * 3000.f;

        FHitResult HitResult;
        FCollisionQueryParams Params;
        Params.AddIgnoredActor(Owner);

        bool bHit = GetWorld()-&amp;gt;LineTraceSingleByChannel(
            HitResult,
            TraceStart,
            TraceEnd,
            ECC_Visibility,
            Params
            );

        FVector TargetPoint = bHit ? HitResult.ImpactPoint : TraceEnd;

        ShootDirection = (TargetPoint - SpawnLocation).GetSafeNormal();
    }
}

SpawnLocation += ShootDirection * 40.f;
SpawnLocation += FVector(0.f, 0.f, 10.f);

AGrenadeProjectile* Grenade = GetWorld()-&amp;gt;SpawnActor&amp;lt;AGrenadeProjectile&amp;gt;(
    GrenadeProjectileClass,
    SpawnLocation,
    ShootDirection.Rotation(),
    SpawnParams
    );&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;- 카메라 LineTrace 기준으로 목표 지점을 계산하고 손 소켓에서 그 방향으로 발사로 해결&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 3. 폭발 연출과 데미지 처리가 분리되어 어색함&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779806654080&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ========== 기존 코드 ========== // 

// CombatComponent.cpp

FVector ExplodeLocation = BaseStart + (ShootDirection * 400.f);

DrawDebugSphere(
    GetWorld(),
    ExplodeLocation,
    GrenadeRadius,
    16,
    FColor::Purple,
    false,
    2.0f,
    0,
    1.5f
    );

TArray&amp;lt;AActor*&amp;gt; IgnoreActors;
IgnoreActors.Add(Owner);

UGameplayStatics::ApplyRadialDamage(
    GetWorld(),
    GrenadeDamage,
    ExplodeLocation,
    GrenadeRadius,
    UDamageType::StaticClass(),
    IgnoreActors,
    Owner,
    Owner-&amp;gt;GetInstigatorController(),
    true
    );&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1779806759458&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ========== 수정후 코드 ========== // 

// CombatComponent.cpp

AGrenadeProjectile* Grenade = GetWorld()-&amp;gt;SpawnActor&amp;lt;AGrenadeProjectile&amp;gt;(
    GrenadeProjectileClass,
    SpawnLocation,
    ShootDirection.Rotation(),
    SpawnParams
    );

if (Grenade)
{
    Grenade-&amp;gt;SetExplosionData(GrenadeDamage, GrenadeRadius);
}

// GrenadeProjectile.cpp

void AGrenadeProjectile::SetExplosionData(float InDamage, float InRadius)
{
    ExplosionDamage = InDamage;
    ExplosionRadius = InRadius;
}

void AGrenadeProjectile::Explode()
{
    if (bHasExploded) return;
    bHasExploded = true;

    FVector ExplodeLocation = GetActorLocation();

    if (GrenadeEffect)
    {
        UGameplayStatics::SpawnEmitterAtLocation(
            GetWorld(),
            GrenadeEffect,
            ExplodeLocation
        );
    }

    if (GrenadeSound)
    {
        UGameplayStatics::PlaySoundAtLocation(
            GetWorld(),
            GrenadeSound,
            ExplodeLocation,
            GrenadeSoundVolume
            );
    }

    TArray&amp;lt;AActor*&amp;gt; IgnoreActors;
    if (GetOwner())
    {
        IgnoreActors.Add(GetOwner());
    }

    UGameplayStatics::ApplyRadialDamage(
        GetWorld(),
        ExplosionDamage,
        ExplodeLocation,
        ExplosionRadius,
        UDamageType::StaticClass(),
        IgnoreActors,
        this,
        GetInstigatorController(),
        true
        );

    Destroy();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;- Explode 함수 안에서 이펙트, 사운드, 범위 데미지를 한 번에 처리로 해결&lt;/p&gt;</description>
      <category>Unreal C++</category>
      <author>hyunjunstar</author>
      <guid isPermaLink="true">https://hyunjunstar.tistory.com/102</guid>
      <comments>https://hyunjunstar.tistory.com/102#entry102comment</comments>
      <pubDate>Tue, 26 May 2026 23:55:50 +0900</pubDate>
    </item>
    <item>
      <title>Unreal C++ 싱글플레이 FPS 슈터 게임 팀 프로젝트 적 AI - 4</title>
      <link>https://hyunjunstar.tistory.com/101</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://hyunjunstar.tistory.com/100&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://hyunjunstar.tistory.com/100&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 글인 위 링크의 Unreal C++ 싱글플레이 FPS 슈터 게임 팀 프로젝트 적 AI - 3 에서는 Behavior Tree와 Blackboard의 블루프린트 구조를 정리하고, EnemyCharacter 블루프린트를 생성 및 Nav Mesh Bounds Volume를 설정 하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 2, 3 단계 구조에는 첫번째로 TargetActor 갱신이 한번만 실행되는 문제와,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ApplyDamage()를 즉시 호출 하는 문제가 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 이 부분을 수정하고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Montage와 Anim Notify, Anim Graph를 사용하여 더 자연스러운 AI 캐릭터를 만들어 보려고 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;C++ Code&lt;/h2&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;EnemyAIController.cpp&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SetTimer()를 반복 실행으로 변경하였다. &lt;br /&gt;이제&amp;nbsp;0.2초마다&amp;nbsp;플레이어를&amp;nbsp;감지하고,&amp;nbsp;플레이어가 감지 범위 안에 있을 때만 Blackboard의 TargetActor를 설정한다.&lt;br /&gt;감지&amp;nbsp;범위&amp;nbsp;밖으로&amp;nbsp;나가면&amp;nbsp;TargetActor를&amp;nbsp;제거하도록&amp;nbsp;수정하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1779466282967&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;GetWorldTimerManager().SetTimer(
    FindPlayerTimerHandle,
    this,
    &amp;amp;AEnemyAIController::SetTargetPlayer,
    0.2f,
    true
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는&amp;nbsp;마지막&amp;nbsp;인자가&amp;nbsp;false였기&amp;nbsp;때문에&amp;nbsp;SetTargetPlayer()가&amp;nbsp;한&amp;nbsp;번만&amp;nbsp;실행되었다. &lt;br /&gt;그래서 이번에는 true로 변경해 0.2초마다 반복 실행되도록 했다.&lt;br /&gt;또한&amp;nbsp;SetTargetPlayer()에서는&amp;nbsp;플레이어와&amp;nbsp;적&amp;nbsp;사이의&amp;nbsp;거리를&amp;nbsp;계산한다.&lt;/p&gt;
&lt;pre id=&quot;code_1779470466527&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const float DistanceToPlayer = FVector::Dist(
    ControlledEnemy-&amp;gt;GetActorLocation(),
    TargetPlayer-&amp;gt;GetActorLocation()
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 감지 범위 안에 있을때만 TargetActor를 저장한다.&lt;/p&gt;
&lt;pre id=&quot;code_1779470509903&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;if (DistanceToPlayer &amp;lt;= ControlledEnemy-&amp;gt;DetectionRange)
{
    BB-&amp;gt;SetValueAsObject(TEXT(&quot;TargetActor&quot;), TargetPlayer);
}
else
{
    BB-&amp;gt;ClearValue(TEXT(&quot;TargetActor&quot;));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 수정하면 플레이어가 감지 범위 밖으로 나갔을 때 Behavior Tree가 더 이상 추적이나 공격을 하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;EnemyCharacter.h&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;공격&amp;nbsp;/&amp;nbsp;사망&amp;nbsp;애니메이션,&amp;nbsp;공격&amp;nbsp;쿨타임,&amp;nbsp;실제&amp;nbsp;데미지&amp;nbsp;적용을&amp;nbsp;위한&amp;nbsp;함수들을&amp;nbsp;추가하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1779466599078&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;UFUNCTION(BlueprintImplementableEvent, BlueprintCallable, Category = &quot;Enemy|Animation&quot;)
void PlayAttackAnimation();

UFUNCTION(BlueprintImplementableEvent, BlueprintCallable, Category = &quot;Enemy|Animation&quot;)
void PlayDeathAnimation();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PlayAttackAnimation()과&amp;nbsp;PlayDeathAnimation()은&amp;nbsp;C++에서&amp;nbsp;호출하고,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 몽타주 재생은 BP_EnemyCharacter에서 구현하기 위해 BlueprintImplementableEvent로 선언하였다.&lt;br /&gt;공격&amp;nbsp;쿨타임을&amp;nbsp;관리하기&amp;nbsp;위한&amp;nbsp;함수도&amp;nbsp;추가하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1779470827464&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;bool CanAttack() const;
void MarkAttack();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고&amp;nbsp;공격&amp;nbsp;몽타주의&amp;nbsp;AttackHit&amp;nbsp;Notify&amp;nbsp;시점에&amp;nbsp;실제&amp;nbsp;데미지를&amp;nbsp;적용할&amp;nbsp;함수도&amp;nbsp;추가하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1779470841705&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;UFUNCTION(BlueprintCallable, Category = &quot;Enemy|Attack&quot;)
void ApplyAttackDamage();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는&amp;nbsp;BTTask_AttackTarget에서&amp;nbsp;바로&amp;nbsp;ApplyDamage()를&amp;nbsp;호출했지만,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 ApplyAttackDamage()에서 실제 데미지를 적용하도록 변경하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;EnemyCharacter.cpp&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;공격&amp;nbsp;쿨타임,&amp;nbsp;RVO&amp;nbsp;회피,&amp;nbsp;회전&amp;nbsp;설정,&amp;nbsp;실제&amp;nbsp;데미지&amp;nbsp;적용,&amp;nbsp;사망&amp;nbsp;애니메이션&amp;nbsp;처리를&amp;nbsp;추가하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1779468532739&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 공격 쿨타임 초기화
LastAttackTime = -AttackCooldown;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음부터 바로 공격할 수 있도록 LastAttackTime을 -AttackCooldown으로 초기화 하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1779470939919&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// RVO 회피 설정
GetCharacterMovement()-&amp;gt;bUseRVOAvoidance = true;
GetCharacterMovement()-&amp;gt;AvoidanceConsiderationRadius = 300.f;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI가 이동할 때 장애물에 막히거나 서로 겹치는 상황을 방지하기 위해 RVO를 활성화 하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1779470981081&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 이동 방향 회전 설정
bUseControllerRotationYaw = false;
GetCharacterMovement()-&amp;gt;bOrientRotationToMovement = true;
GetCharacterMovement()-&amp;gt;RotationRate = FRotator(0.f, 720.f, 0.f);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI가 이동할 때 이동 방향을 바라보도록 설정 하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1779471005336&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 공격 쿨타임 처리
bool AEnemyCharacter::CanAttack() const
{
    const UWorld* World = GetWorld();
    if (!World)
    {
        return false;
    }

    return World-&amp;gt;GetTimeSeconds() - LastAttackTime &amp;gt;= AttackCooldown;
}

void AEnemyCharacter::MarkAttack()
{
    const UWorld* World = GetWorld();
    if (!World)
    {
        return;
    }

    LastAttackTime = World-&amp;gt;GetTimeSeconds();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CanAttack()은 마지막 공격 이후 AttackCooldown만큼 시간이 지났는지 계산 하도록 하였고,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MarkAttack()은 공격을 시작한 시간을 LastAttackTime에 저장하도록 하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1779471099193&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 실제 데미지 적용
void AEnemyCharacter::ApplyAttackDamage()
{
    if (bIsDead)
    {
        return;
    }

    AAIController* AIController = Cast&amp;lt;AAIController&amp;gt;(GetController());
    if (!AIController)
    {
        return;
    }

    UBlackboardComponent* BlackboardComp = AIController-&amp;gt;GetBlackboardComponent();
    if (!BlackboardComp)
    {
        return;
    }

    AActor* TargetActor = Cast&amp;lt;AActor&amp;gt;(
        BlackboardComp-&amp;gt;GetValueAsObject(TEXT(&quot;TargetActor&quot;))
    );

    if (!TargetActor)
    {
        return;
    }

    const float DistanceToTarget = FVector::Dist(
        GetActorLocation(),
        TargetActor-&amp;gt;GetActorLocation()
    );

    if (DistanceToTarget &amp;gt; AttackRange + 50.f)
    {
        return;
    }

    UGameplayStatics::ApplyDamage(
        TargetActor,
        AttackDamage,
        AIController,
        this,
        nullptr
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ApplyAttackDamage()는 공격 몽타주의 AttackHit Notify에서 호출 되도록 하였고,&amp;nbsp;&lt;br /&gt;이 함수에서는 Blackboard의 TargetActor를 가져온 뒤, 공격 가능한 거리 안에 있는지 다시 확인한다. &lt;br /&gt;공격 모션이 재생되는 동안 플레이어가 멀리 벗어날 수 있기 때문에 데미지를 적용하기 직전에 거리를 한 번 더 검사하였다.&lt;br /&gt;조건을 만족하면 UGameplayStatics::ApplyDamage()를 호출해 플레이어에게 데미지를 주도록 하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1779471155713&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 사망 처리 변경
GetCharacterMovement()-&amp;gt;DisableMovement();
GetCharacterMovement()-&amp;gt;StopMovementImmediately();
GetCapsuleComponent()-&amp;gt;SetCollisionEnabled(ECollisionEnabled::NoCollision);

PlayDeathAnimation();

SetLifeSpan(3.0f);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;적이 죽으면 이동을 비활성화하고 정지 시킨 후,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;충돌을 제거한 뒤 PlayDeathAnimation()을 호출해 사망 몽타주를 재생한다.&lt;br /&gt;그리고 사망 애니메이션이 보일 시간을 준 뒤 3초 후 제거하도록 변경하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;BTTask_AttackTarget.cpp&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전에는 공격 Task가 실행되면 바로 ApplyDamage()를 호출 하였는데,&amp;nbsp;&lt;br /&gt;이번에는 ApplyDamage()를 제거하고, 공격 몽타주만 재생하도록 변경하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1779469389894&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 공격 쿨타임 확인
if (!Enemy-&amp;gt;CanAttack())
{
    return EBTNodeResult::Failed;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공격 Task가 실행되기 전에 CanAttack()을 호출하여 쿨타임이 끝났는지 확인 하도록 하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1779471301080&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 이동 정지
AIController-&amp;gt;StopMovement();
AIController-&amp;gt;ClearFocus(EAIFocusPriority::Gameplay);

Enemy-&amp;gt;GetCharacterMovement()-&amp;gt;StopMovementImmediately();&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;공격 중에는 이동하지 않도록 이동을 멈추고, CharacterMovement도 정지 시켰다.&lt;/p&gt;
&lt;pre id=&quot;code_1779471352438&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 플레이어 방향으로 회전
FVector Direction = TargetActor-&amp;gt;GetActorLocation() - Enemy-&amp;gt;GetActorLocation();
Direction.Z = 0.f;

if (!Direction.IsNearlyZero())
{
    FRotator LookRotation = Direction.Rotation();

    Enemy-&amp;gt;SetActorRotation(LookRotation);
    AIController-&amp;gt;SetControlRotation(LookRotation);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;공격하기 직전에 플레이어 방향을 바라보도록 회전 시켰다.&lt;/p&gt;
&lt;pre id=&quot;code_1779471378197&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 공격 몽타주 재생
Enemy-&amp;gt;PlayAttackAnimation();
Enemy-&amp;gt;MarkAttack();&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;PlayAttackAnimation()을&amp;nbsp;호출해&amp;nbsp;BP_EnemyCharacter에서&amp;nbsp;공격&amp;nbsp;몽타주를&amp;nbsp;재생한다. &lt;br /&gt;그리고 MarkAttack()으로 공격 시작 시간을 기록하고,&amp;nbsp;&lt;br /&gt;공격 몽타주의 AttackHit Anim Notify에서 ApplyAttackDamage()가 호출될 때 데미지를 주도록 하였다.&lt;/p&gt;
&lt;h2 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;Montage&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;몽타주 원본 파일 우클릭 후 Create AnimMontage 선택해서 몽타주를 생성 하였고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공격하는 모션이 실행 된 후 실제 타격이 발생하는 지점에 AttackHit Notify를 추가하였고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 Notify가 실행되는 시점에 EnemyCharacter의 ApplyAttackDamage()를 호출하여&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플레이어에게 데미지를 입히도록 설정하였다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1440&quot; data-origin-height=&quot;955&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cWcoZO/dJMcajbaKR4/TuIu8TKPKhq2SK8XHDSOpk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cWcoZO/dJMcajbaKR4/TuIu8TKPKhq2SK8XHDSOpk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cWcoZO/dJMcajbaKR4/TuIu8TKPKhq2SK8XHDSOpk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcWcoZO%2FdJMcajbaKR4%2FTuIu8TKPKhq2SK8XHDSOpk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1440&quot; height=&quot;955&quot; data-origin-width=&quot;1440&quot; data-origin-height=&quot;955&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;Blueprint&lt;/h2&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;ABP_EnemyCharacer&lt;/h3&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;Event Graph&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. AnimNotify_AttackHit&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;공격 몽타주에 추가한 AttackHit Notify가 실행될 때 호출된다.&lt;br /&gt;Try&amp;nbsp;Get&amp;nbsp;Pawn&amp;nbsp;Owner로&amp;nbsp;현재&amp;nbsp;애니메이션&amp;nbsp;블루프린트를&amp;nbsp;사용하는&amp;nbsp;Pawn을&amp;nbsp;가져오고,&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;BP_EnemyCharacter로 Cast한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Cast에&amp;nbsp;성공하면&amp;nbsp;EnemyCharacter에&amp;nbsp;구현해둔&amp;nbsp;ApplyAttackDamage()를&amp;nbsp;호출한다. &lt;br /&gt;이렇게&amp;nbsp;해서&amp;nbsp;공격&amp;nbsp;모션의&amp;nbsp;실제&amp;nbsp;타격&amp;nbsp;프레임에&amp;nbsp;플레이어에게&amp;nbsp;데미지가&amp;nbsp;들어가도록&amp;nbsp;만들었다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2.Speed&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Try Get Pawn Owner로 현재 애니메이션을 사용하는 Pawn을 가져오고, Is Valid로 유효한지 확인한다.&lt;br /&gt;유효하면&amp;nbsp;Get&amp;nbsp;Velocity로&amp;nbsp;캐릭터의&amp;nbsp;현재&amp;nbsp;속도&amp;nbsp;벡터를&amp;nbsp;가져온다. &lt;br /&gt;이후 Vector Length XY를 사용해 Z축을 제외한 이동 속도를 계산하고, 그 값을 Speed 변수에 저장한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Speed 값 0 = Idle &lt;br /&gt;Speed 값 증가 = Walk / Run&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;937&quot; data-origin-height=&quot;819&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/DXE0c/dJMcagMqiHM/QMlPXbAIfrPsAuMf8vrnIk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/DXE0c/dJMcagMqiHM/QMlPXbAIfrPsAuMf8vrnIk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/DXE0c/dJMcagMqiHM/QMlPXbAIfrPsAuMf8vrnIk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDXE0c%2FdJMcagMqiHM%2FQMlPXbAIfrPsAuMf8vrnIk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;937&quot; height=&quot;819&quot; data-origin-width=&quot;937&quot; data-origin-height=&quot;819&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;Anim Graph&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EventGraph에서 계산한 Speed 값을 BS_MM_WalkRun Blend Space에 전달하여 Idle / Walk / Run 애니메이션이 전환되도록 하였다.&lt;br /&gt;그리고&amp;nbsp;Blend&amp;nbsp;Space&amp;nbsp;뒤에&amp;nbsp;Slot&amp;nbsp;'DefaultSlot'을&amp;nbsp;연결하였다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 Slot을 통해 공격 / 사망 몽타주가 기본 이동 애니메이션 위에 재생될 수 있도록 만들었다.&lt;br /&gt;마지막으로&amp;nbsp;Slot의&amp;nbsp;결과를&amp;nbsp;Output&amp;nbsp;Pose에&amp;nbsp;연결하여&amp;nbsp;AI&amp;nbsp;캐릭터의&amp;nbsp;최종&amp;nbsp;애니메이션으로&amp;nbsp;출력되도록&amp;nbsp;하였다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;937&quot; data-origin-height=&quot;784&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bbXOLq/dJMcadvhvpG/jA0BVnl0SApNnqQv647wa1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bbXOLq/dJMcadvhvpG/jA0BVnl0SApNnqQv647wa1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bbXOLq/dJMcadvhvpG/jA0BVnl0SApNnqQv647wa1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbbXOLq%2FdJMcadvhvpG%2FjA0BVnl0SApNnqQv647wa1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;937&quot; height=&quot;784&quot; data-origin-width=&quot;937&quot; data-origin-height=&quot;784&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;BP_EnemyCharacer&lt;/h3&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;Event Graph&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. Montage Attack&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PlayAttackAnimation&amp;nbsp;이벤트는&amp;nbsp;C++의&amp;nbsp;EnemyCharacter에서&amp;nbsp;호출하는&amp;nbsp;Blueprint&amp;nbsp;이벤트다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 이벤트가 실행되면 Attack Montages 배열에 들어 있는 공격 몽타주 중 하나를 랜덤으로 선택한다. &lt;br /&gt;먼저&amp;nbsp;배열의&amp;nbsp;Length가&amp;nbsp;0보다&amp;nbsp;큰지&amp;nbsp;확인하고,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배열이 비어 있지 않으면 Random Integer in Range로 랜덤 인덱스를 만든다.&lt;br /&gt;그&amp;nbsp;후&amp;nbsp;Attack&amp;nbsp;Montages&amp;nbsp;배열에서&amp;nbsp;해당&amp;nbsp;인덱스의&amp;nbsp;몽타주를&amp;nbsp;가져오고,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Mesh의 Anim Instance를 Target으로 넘겨 Montage Play를 실행한다.&lt;br /&gt;공격할 때마다 Attack Montages 배열에 등록된 공격 모션 중 하나가 랜덤으로 재생되는 구조로 설계하였다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1141&quot; data-origin-height=&quot;789&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dmS58E/dJMcabK8iy6/1Uxnp2atSDhbKfjUcHkdUk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dmS58E/dJMcabK8iy6/1Uxnp2atSDhbKfjUcHkdUk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dmS58E/dJMcabK8iy6/1Uxnp2atSDhbKfjUcHkdUk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdmS58E%2FdJMcabK8iy6%2F1Uxnp2atSDhbKfjUcHkdUk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1141&quot; height=&quot;789&quot; data-origin-width=&quot;1141&quot; data-origin-height=&quot;789&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2&lt;/b&gt;&lt;b&gt;. Montage Damage&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Event&amp;nbsp;AnyDamage는&amp;nbsp;BP_EnemyCharacter가&amp;nbsp;데미지를&amp;nbsp;받았을&amp;nbsp;때&amp;nbsp;호출된다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;데미지가 정상적으로 들어오는지 확인하기 위해 Print String을 사용하였다.&lt;br /&gt;Damage&amp;nbsp;값과&amp;nbsp;Current&amp;nbsp;HP&amp;nbsp;값을&amp;nbsp;Append로&amp;nbsp;합쳐서&amp;nbsp;&quot;Enemy&amp;nbsp;Damage:&amp;nbsp;/&amp;nbsp;HP:&quot;&amp;nbsp;형태의&amp;nbsp;문자열을&amp;nbsp;만들고,&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;화면에&amp;nbsp;출력하도록&amp;nbsp;설계하였다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;979&quot; data-origin-height=&quot;593&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cuLOwA/dJMcajvy1l8/OoijtKikxmBKETich7S4y1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cuLOwA/dJMcajvy1l8/OoijtKikxmBKETich7S4y1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cuLOwA/dJMcajvy1l8/OoijtKikxmBKETich7S4y1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcuLOwA%2FdJMcajvy1l8%2FOoijtKikxmBKETich7S4y1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;979&quot; height=&quot;593&quot; data-origin-width=&quot;979&quot; data-origin-height=&quot;593&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. Montage Die&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;PlayDeathAnimation 이벤트는 C++의 EnemyCharacter::Die()에서 호출된다.&lt;br /&gt;적의 HP가 0 이하가 되면 C++ 코드에서 이동과 충돌을 비활성화한 뒤 PlayDeathAnimation()을 호출한다.&lt;br /&gt;BP_EnemyCharacter에서는&amp;nbsp;이&amp;nbsp;이벤트를&amp;nbsp;받아&amp;nbsp;Mesh의&amp;nbsp;Anim&amp;nbsp;Instance를&amp;nbsp;가져오고,&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Montage&amp;nbsp;Play로&amp;nbsp;AM_Enemy_Die&amp;nbsp;사망&amp;nbsp;몽타주를&amp;nbsp;재생한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;949&quot; data-origin-height=&quot;626&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ocrkK/dJMcagZSFwq/zljyyXkS2kaMIxPkNKO2Z1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ocrkK/dJMcagZSFwq/zljyyXkS2kaMIxPkNKO2Z1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ocrkK/dJMcagZSFwq/zljyyXkS2kaMIxPkNKO2Z1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FocrkK%2FdJMcagZSFwq%2FzljyyXkS2kaMIxPkNKO2Z1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;949&quot; height=&quot;626&quot; data-origin-width=&quot;949&quot; data-origin-height=&quot;626&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;최종 변경 후 구조&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;최종 버전에서는 Behavior Tree 구조는 유지하면서,&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;TargetActor 갱신 방식과 공격 데미지 적용 방식을 개선하였다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;EnemyAIController&lt;/b&gt; &lt;br /&gt;&amp;nbsp;-&amp;nbsp;Behavior&amp;nbsp;Tree&amp;nbsp;실행 &lt;br /&gt;&amp;nbsp;-&amp;nbsp;0.2초마다&amp;nbsp;플레이어&amp;nbsp;감지 &lt;br /&gt;&amp;nbsp;-&amp;nbsp;감지&amp;nbsp;범위&amp;nbsp;안이면&amp;nbsp;Blackboard에&amp;nbsp;TargetActor&amp;nbsp;설정 &lt;br /&gt;&amp;nbsp;-&amp;nbsp;감지&amp;nbsp;범위&amp;nbsp;밖이면&amp;nbsp;TargetActor&amp;nbsp;제거 &lt;br /&gt;&lt;br /&gt;&lt;b&gt;EnemyCharacter&lt;/b&gt; &lt;br /&gt;&amp;nbsp;- Behavior Tree asset 소유&lt;br /&gt;&amp;nbsp;-&amp;nbsp;ApplyDamage를&amp;nbsp;TakeDamage로&amp;nbsp;받아서 HP를 처리 &lt;br /&gt;&amp;nbsp;-&amp;nbsp;공격&amp;nbsp;쿨타임&amp;nbsp;관리 &lt;br /&gt;&amp;nbsp;-&amp;nbsp;공격&amp;nbsp;/&amp;nbsp;사망&amp;nbsp;몽타주&amp;nbsp;재생&amp;nbsp;이벤트&amp;nbsp;제공 &lt;br /&gt;&amp;nbsp;-&amp;nbsp;Anim&amp;nbsp;Notify&amp;nbsp;시점에&amp;nbsp;실제&amp;nbsp;데미지&amp;nbsp;적용 &lt;br /&gt;&amp;nbsp;-&amp;nbsp;사망&amp;nbsp;시&amp;nbsp;이동/충돌&amp;nbsp;비활성화&amp;nbsp;후&amp;nbsp;사망&amp;nbsp;몽타주&amp;nbsp;재생 &lt;br /&gt;&amp;nbsp;-&amp;nbsp;RVO&amp;nbsp;회피&amp;nbsp;및&amp;nbsp;이동&amp;nbsp;방향&amp;nbsp;회전&amp;nbsp;처리 &lt;br /&gt;&lt;br /&gt;&lt;b&gt;BTDecorator_IsInAttackRange&lt;/b&gt; &lt;br /&gt;&amp;nbsp;-&amp;nbsp;TargetActor가&amp;nbsp;공격&amp;nbsp;범위&amp;nbsp;안에&amp;nbsp;있는지&amp;nbsp;검사 &lt;br /&gt;&lt;br /&gt;&lt;b&gt;BTTask_AttackTarget&lt;/b&gt; &lt;br /&gt;&amp;nbsp;-&amp;nbsp;공격&amp;nbsp;쿨타임&amp;nbsp;확인 &lt;br /&gt;&amp;nbsp;-&amp;nbsp;이동&amp;nbsp;정지 &lt;br /&gt;&amp;nbsp;-&amp;nbsp;플레이어&amp;nbsp;방향으로&amp;nbsp;회전 &lt;br /&gt;&amp;nbsp;-&amp;nbsp;공격&amp;nbsp;몽타주&amp;nbsp;재생 &lt;br /&gt;&amp;nbsp;-&amp;nbsp;직접&amp;nbsp;데미지를&amp;nbsp;주지&amp;nbsp;않음&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Montage&lt;/b&gt;&lt;br /&gt;&amp;nbsp;-&amp;nbsp;공격&amp;nbsp;/&amp;nbsp;사망&amp;nbsp;애니메이션을&amp;nbsp;원하는&amp;nbsp;시점에&amp;nbsp;재생하기&amp;nbsp;위해&amp;nbsp;사용&lt;br /&gt;&amp;nbsp;-&amp;nbsp;공격&amp;nbsp;몽타주에&amp;nbsp;AttackHit&amp;nbsp;Notify&amp;nbsp;추가&lt;br /&gt;&amp;nbsp;-&amp;nbsp;Notify&amp;nbsp;시점에&amp;nbsp;실제&amp;nbsp;데미지&amp;nbsp;적용&lt;br /&gt;&lt;br /&gt;&lt;b&gt;ABP_EnemyCharacter&lt;/b&gt;&lt;br /&gt;&amp;nbsp;-&amp;nbsp;Speed&amp;nbsp;값을&amp;nbsp;계산해&amp;nbsp;Idle&amp;nbsp;/&amp;nbsp;Walk&amp;nbsp;/&amp;nbsp;Run&amp;nbsp;전환&lt;br /&gt;&amp;nbsp;-&amp;nbsp;AnimGraph에서&amp;nbsp;Blend&amp;nbsp;Space와&amp;nbsp;DefaultSlot&amp;nbsp;연결&lt;br /&gt;&amp;nbsp;-&amp;nbsp;AttackHit&amp;nbsp;Notify&amp;nbsp;발생&amp;nbsp;시&amp;nbsp;ApplyAttackDamage()&amp;nbsp;호출&lt;br /&gt;&lt;br /&gt;&lt;b&gt;BP_EnemyCharacter&lt;/b&gt;&lt;br /&gt;&amp;nbsp;-&amp;nbsp;PlayAttackAnimation&amp;nbsp;이벤트에서&amp;nbsp;공격&amp;nbsp;몽타주&amp;nbsp;랜덤&amp;nbsp;재생&lt;br /&gt;&amp;nbsp;-&amp;nbsp;PlayDeathAnimation&amp;nbsp;이벤트에서&amp;nbsp;사망&amp;nbsp;몽타주&amp;nbsp;재생&lt;br /&gt;&amp;nbsp;-&amp;nbsp;Event&amp;nbsp;AnyDamage로&amp;nbsp;데미지&amp;nbsp;/&amp;nbsp;HP&amp;nbsp;디버그&amp;nbsp;출력&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;1.&amp;nbsp;EnemyAIController가&amp;nbsp;EnemyCharacter를&amp;nbsp;Possess &lt;br /&gt;2.&amp;nbsp;EnemyCharacter에&amp;nbsp;설정된&amp;nbsp;Behavior&amp;nbsp;Tree&amp;nbsp;실행 &lt;br /&gt;3.&amp;nbsp;0.2초마다&amp;nbsp;플레이어&amp;nbsp;Pawn&amp;nbsp;탐색 &lt;br /&gt;4.&amp;nbsp;감지&amp;nbsp;범위&amp;nbsp;안이면&amp;nbsp;Blackboard에&amp;nbsp;TargetActor&amp;nbsp;저장 &lt;br /&gt;5.&amp;nbsp;감지&amp;nbsp;범위&amp;nbsp;밖이면&amp;nbsp;TargetActor&amp;nbsp;제거 &lt;br /&gt;6.&amp;nbsp;Behavior&amp;nbsp;Tree가&amp;nbsp;TargetActor를&amp;nbsp;기준으로&amp;nbsp;이동&amp;nbsp;/&amp;nbsp;공격&amp;nbsp;판단 &lt;br /&gt;7.&amp;nbsp;BTDecorator_IsInAttackRange가&amp;nbsp;공격&amp;nbsp;거리&amp;nbsp;검사 &lt;br /&gt;8.&amp;nbsp;공격&amp;nbsp;범위&amp;nbsp;안이면&amp;nbsp;BTTask_AttackTarget&amp;nbsp;실행 &lt;br /&gt;9.&amp;nbsp;BTTask_AttackTarget이&amp;nbsp;이동을&amp;nbsp;멈추고&amp;nbsp;플레이어&amp;nbsp;방향으로&amp;nbsp;회전 &lt;br /&gt;10.&amp;nbsp;PlayAttackAnimation()&amp;nbsp;호출 &lt;br /&gt;11.&amp;nbsp;BP_EnemyCharacter에서&amp;nbsp;공격&amp;nbsp;몽타주&amp;nbsp;랜덤&amp;nbsp;재생 &lt;br /&gt;12.&amp;nbsp;공격&amp;nbsp;몽타주의&amp;nbsp;AttackHit&amp;nbsp;Notify&amp;nbsp;발생 &lt;br /&gt;13.&amp;nbsp;ABP_EnemyCharacter의&amp;nbsp;AnimNotify_AttackHit에서&amp;nbsp;BP_EnemyCharacter로&amp;nbsp;Cast &lt;br /&gt;14.&amp;nbsp;ApplyAttackDamage()&amp;nbsp;호출 &lt;br /&gt;15.&amp;nbsp;ApplyDamage()로&amp;nbsp;플레이어에게&amp;nbsp;실제&amp;nbsp;데미지&amp;nbsp;적용 &lt;br /&gt;16.&amp;nbsp;적&amp;nbsp;사망&amp;nbsp;시&amp;nbsp;PlayDeathAnimation()&amp;nbsp;호출 &lt;br /&gt;17.&amp;nbsp;BP_EnemyCharacter에서&amp;nbsp;사망&amp;nbsp;몽타주&amp;nbsp;재생 &lt;br /&gt;18.&amp;nbsp;3초&amp;nbsp;뒤&amp;nbsp;액터&amp;nbsp;제거&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번&amp;nbsp;버전에서는&amp;nbsp;SetTargetPlayer()를&amp;nbsp;0.2초마다&amp;nbsp;반복&amp;nbsp;실행하도록&amp;nbsp;수정하고,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플레이어가&amp;nbsp;감지&amp;nbsp;범위&amp;nbsp;밖으로&amp;nbsp;나가면&amp;nbsp;Blackboard의&amp;nbsp;TargetActor를&amp;nbsp;제거하도록&amp;nbsp;변경하였다. &lt;br /&gt;&lt;br /&gt;또한&amp;nbsp;BTTask_AttackTarget에서&amp;nbsp;직접&amp;nbsp;ApplyDamage()를&amp;nbsp;호출하던&amp;nbsp;구조를&amp;nbsp;제거하고,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PlayAttackAnimation()만&amp;nbsp;실행하도록&amp;nbsp;수정하였다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제&amp;nbsp;데미지는&amp;nbsp;공격&amp;nbsp;몽타주의&amp;nbsp;AttackHit&amp;nbsp;Notify&amp;nbsp;시점에&amp;nbsp;ApplyAttackDamage()가&amp;nbsp;호출되면서&amp;nbsp;적용된다. &lt;br /&gt;&lt;br /&gt;공격&amp;nbsp;쿨타임은&amp;nbsp;EnemyCharacter의&amp;nbsp;CanAttack(),&amp;nbsp;MarkAttack()으로&amp;nbsp;관리하도록&amp;nbsp;옮겼고,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공격 시 이동 정지와 플레이어 방향 회전도 추가하였다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사망&amp;nbsp;시에는&amp;nbsp;이동과&amp;nbsp;충돌을&amp;nbsp;끄고&amp;nbsp;PlayDeathAnimation()을&amp;nbsp;호출한&amp;nbsp;뒤&amp;nbsp;3초&amp;nbsp;후&amp;nbsp;제거되도록&amp;nbsp;변경하였다. &lt;br /&gt;&lt;br /&gt;결론은 플레이어 감지 흐름이 더 자연스러워졌고, 공격 애니메이션과 실제 데미지 적용 타이밍을 맞출 수 있었다.&lt;/p&gt;</description>
      <category>Unreal C++</category>
      <author>hyunjunstar</author>
      <guid isPermaLink="true">https://hyunjunstar.tistory.com/101</guid>
      <comments>https://hyunjunstar.tistory.com/101#entry101comment</comments>
      <pubDate>Sat, 23 May 2026 02:37:02 +0900</pubDate>
    </item>
    <item>
      <title>Unreal C++ 싱글플레이 FPS 슈터 게임 팀 프로젝트 적 AI - 3</title>
      <link>https://hyunjunstar.tistory.com/100</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://hyunjunstar.tistory.com/99&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://hyunjunstar.tistory.com/99&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 글인 위 링크의 Unreal C++ 싱글플레이 FPS 슈터 게임 팀 프로젝트 적 AI - 2 에서는 Behavior Tree와 Blackboard 기반으로 적 AI 캐릭터를 구현 하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 Behavior Tree와 Blackboard의 블루프린트 구조를 정리하고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EnemyCharacter 블루프린트를 생성 해보려고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Blackboard&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;New Key로 TargetActor를 추가하여 Key Type은 Object로 설정하고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Base Class에 Actor를 지정하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TargetActor는 AI가 추적하거나 공격할 대상을 저장하는 값이며,&amp;nbsp;&lt;br /&gt;EnemyAIController에서&amp;nbsp;플레이어를&amp;nbsp;감지하면&amp;nbsp;Blackboard의&amp;nbsp;TargetActor에&amp;nbsp;플레이어&amp;nbsp;Pawn을&amp;nbsp;저장하고,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Behavior&amp;nbsp;Tree는&amp;nbsp;이&amp;nbsp;값을&amp;nbsp;기준으로&amp;nbsp;추적과&amp;nbsp;공격을&amp;nbsp;판단한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1904&quot; data-origin-height=&quot;374&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cNg4oM/dJMcaiJ7DfH/07xH5nDURRb3QYyc7KlsBk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cNg4oM/dJMcaiJ7DfH/07xH5nDURRb3QYyc7KlsBk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cNg4oM/dJMcaiJ7DfH/07xH5nDURRb3QYyc7KlsBk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcNg4oM%2FdJMcaiJ7DfH%2F07xH5nDURRb3QYyc7KlsBk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1904&quot; height=&quot;374&quot; data-origin-width=&quot;1904&quot; data-origin-height=&quot;374&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Behavior Tree&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Selector를 기준으로 공격, 추적, 대기 흐름을 나누었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TargetActor가&amp;nbsp;존재하고&amp;nbsp;공격&amp;nbsp;범위&amp;nbsp;안에&amp;nbsp;있으면&amp;nbsp;Sequence_Attack이&amp;nbsp;실행된다. &lt;br /&gt;이&amp;nbsp;Sequence&amp;nbsp;안에서는&amp;nbsp;BTDecorator_IsInAttackRange&amp;nbsp;조건을&amp;nbsp;확인한&amp;nbsp;뒤,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BTTask_AttackTarget을 실행해 공격을 실행한다.&lt;br /&gt;&lt;br /&gt;TargetActor는&amp;nbsp;존재하지만&amp;nbsp;공격&amp;nbsp;범위&amp;nbsp;밖이라면&amp;nbsp;Sequence_Chase가&amp;nbsp;실행된다. &lt;br /&gt;이때 Move To 노드를 사용해 플레이어를 추적한다. &lt;br /&gt;&lt;br /&gt;공격과&amp;nbsp;추적&amp;nbsp;조건을&amp;nbsp;모두&amp;nbsp;만족하지&amp;nbsp;않으면&amp;nbsp;Wait&amp;nbsp;노드가&amp;nbsp;실행되어&amp;nbsp;잠시&amp;nbsp;대기하도록&amp;nbsp;구성하였다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1402&quot; data-origin-height=&quot;968&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cGsX7s/dJMcad26N6M/JApQURJKEFAerhRfIYkwk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cGsX7s/dJMcad26N6M/JApQURJKEFAerhRfIYkwk1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cGsX7s/dJMcad26N6M/JApQURJKEFAerhRfIYkwk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcGsX7s%2FdJMcad26N6M%2FJApQURJKEFAerhRfIYkwk1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1402&quot; height=&quot;968&quot; data-origin-width=&quot;1402&quot; data-origin-height=&quot;968&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;BP_EnemyCharacter&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;EnemyCharacter를 상속받는 블루프린트 클래스 생성 후,&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Class Defaults - Details - Pawn 에서 AI Controller Class에 EnemyAIController 지정 후,&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Mesh에서 Skeletal Mesh Asset에 SKM_Manny를 지정해주었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1233&quot; data-origin-height=&quot;223&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/egZqR4/dJMcadhNYqy/ujxLmk4jama00m8n06m5Ck/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/egZqR4/dJMcadhNYqy/ujxLmk4jama00m8n06m5Ck/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/egZqR4/dJMcadhNYqy/ujxLmk4jama00m8n06m5Ck/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FegZqR4%2FdJMcadhNYqy%2FujxLmk4jama00m8n06m5Ck%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1233&quot; height=&quot;223&quot; data-origin-width=&quot;1233&quot; data-origin-height=&quot;223&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;471&quot; data-origin-height=&quot;255&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/DJaQl/dJMcabK6hYW/XdTYO3UKYoKbqkNc4FtofK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/DJaQl/dJMcabK6hYW/XdTYO3UKYoKbqkNc4FtofK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/DJaQl/dJMcabK6hYW/XdTYO3UKYoKbqkNc4FtofK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDJaQl%2FdJMcabK6hYW%2FXdTYO3UKYoKbqkNc4FtofK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;471&quot; height=&quot;255&quot; data-origin-width=&quot;471&quot; data-origin-height=&quot;255&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;ABP_EnemyCharacter&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;SK_Mannequin 스켈레톤 선택 후, AnimInstance를 상속받는 Animation Blueprint 클래스 생성&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;413&quot; data-origin-height=&quot;691&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bQuC1X/dJMb99NbJ16/jDZLaIIlKHYoUW1WBWLdgk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bQuC1X/dJMb99NbJ16/jDZLaIIlKHYoUW1WBWLdgk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bQuC1X/dJMb99NbJ16/jDZLaIIlKHYoUW1WBWLdgk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbQuC1X%2FdJMb99NbJ16%2FjDZLaIIlKHYoUW1WBWLdgk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;413&quot; height=&quot;691&quot; data-origin-width=&quot;413&quot; data-origin-height=&quot;691&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성 후 BP_EnemyCharacter에 Class Defaults - Details -&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;Mesh - Location Z축 -88, Rotation Z축 270도로 설정,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;그리고 Animation - Anim Class에 ABP_EnemyCharacter 지정&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;466&quot; data-origin-height=&quot;414&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/9mShs/dJMcajoGKvb/ifpkB8uNBNukTSmvWgKEw0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/9mShs/dJMcajoGKvb/ifpkB8uNBNukTSmvWgKEw0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/9mShs/dJMcajoGKvb/ifpkB8uNBNukTSmvWgKEw0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F9mShs%2FdJMcajoGKvb%2FifpkB8uNBNukTSmvWgKEw0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;466&quot; height=&quot;414&quot; data-origin-width=&quot;466&quot; data-origin-height=&quot;414&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Nav Mesh Bounds Volume&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;좌측 Place Actors에서 Nav Mesh Bounds Volume 검색 후 배치를 해주었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1522&quot; data-origin-height=&quot;588&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GjsaQ/dJMcajoHwZO/tAlxB81TEcBBZeJo5KbMpk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GjsaQ/dJMcajoHwZO/tAlxB81TEcBBZeJo5KbMpk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GjsaQ/dJMcajoHwZO/tAlxB81TEcBBZeJo5KbMpk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGjsaQ%2FdJMcajoHwZO%2FtAlxB81TEcBBZeJo5KbMpk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1522&quot; height=&quot;588&quot; data-origin-width=&quot;1522&quot; data-origin-height=&quot;588&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 C++로 구현한 적 AI 기능을 기반으로,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Unreal Editor에서 Blackboard, Behavior Tree, BP_EnemyCharacter, ABP_EnemyCharacter를 설정하였다. &lt;br /&gt;&lt;br /&gt;Blackboard에는&amp;nbsp;AI가&amp;nbsp;추적할&amp;nbsp;대상을&amp;nbsp;저장하기&amp;nbsp;위해&amp;nbsp;TargetActor&amp;nbsp;키를&amp;nbsp;추가하였고,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Behavior&amp;nbsp;Tree에서는&amp;nbsp;이&amp;nbsp;값을&amp;nbsp;기준으로&amp;nbsp;공격,&amp;nbsp;추적,&amp;nbsp;대기&amp;nbsp;흐름을&amp;nbsp;구성하였다. &lt;br /&gt;&lt;br /&gt;또한&amp;nbsp;EnemyCharacter를&amp;nbsp;상속받는&amp;nbsp;블루프린트를&amp;nbsp;생성하고&amp;nbsp;EnemyAIController,&amp;nbsp;SKM_Manny,&amp;nbsp;ABP_EnemyCharacter를&amp;nbsp;연결하여&amp;nbsp;적&amp;nbsp;캐릭터가&amp;nbsp;AI&amp;nbsp;컨트롤러와&amp;nbsp;애니메이션&amp;nbsp;블루프린트를&amp;nbsp;기반으로&amp;nbsp;동작할&amp;nbsp;수&amp;nbsp;있도록&amp;nbsp;설정하였다.&lt;/p&gt;</description>
      <category>Unreal C++</category>
      <author>hyunjunstar</author>
      <guid isPermaLink="true">https://hyunjunstar.tistory.com/100</guid>
      <comments>https://hyunjunstar.tistory.com/100#entry100comment</comments>
      <pubDate>Thu, 21 May 2026 21:55:30 +0900</pubDate>
    </item>
    <item>
      <title>Unreal C++ 싱글플레이 FPS 슈터 게임 팀 프로젝트 적 AI - 2</title>
      <link>https://hyunjunstar.tistory.com/99</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://hyunjunstar.tistory.com/97&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://hyunjunstar.tistory.com/97&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 글인 위 링크 의 Unreal C++ 싱글플레이 FPS 슈터 게임 팀 프로젝트 적 AI - 1 에서는 초기 구현을 정리하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 버전에는 EnemyAIController가 Tick에서 매 프레임마다 플레이어와 거리를 계산하고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그에 맞는 상태를(Idle / Chase / Attack / Dead) 직접 관리하는 코드 방식으로 구현했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 구조를 개선하여 Behavior Tree와 Blackboard를 사용하고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NavMesh 기반 경로 탐색과 장애물 회피까지 포함한 AI 구조를 구현해보려고 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Behavior&amp;nbsp;Tree&amp;nbsp;사용 후&amp;nbsp;변경된&amp;nbsp;부분&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존&amp;nbsp;구조에서는&amp;nbsp;EnemyAIController가&amp;nbsp;모든&amp;nbsp;판단을&amp;nbsp;직접&amp;nbsp;처리했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EnemyAIController가 Tick()에서 직접 플레이어와의 거리를 계산하고,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Idle&amp;nbsp;/&amp;nbsp;Chase&amp;nbsp;/&amp;nbsp;Attack&amp;nbsp;/&amp;nbsp;Dead&amp;nbsp;상태를&amp;nbsp;관리하는&amp;nbsp;구조였다. &lt;br /&gt;&lt;br /&gt;이번에는 이 구조를 개선해서 Behavior Tree와 Blackboard를 사용하도록 변경하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;EnemyAIController.h&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에 구현했던 AIController가 직접 판단하고 관리하는 코드들을 제거하고,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Behavior Tree 실행, Blackboard에 TargetActor 설정 하는 식으로 변경하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1779292284154&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// EnemyAIController.h

#pragma once

#include &quot;CoreMinimal.h&quot;
#include &quot;AIController.h&quot;
#include &quot;TimerManager.h&quot;
#include &quot;EnemyAIController.generated.h&quot;

UCLASS()
class CH3_PROJECT_API AEnemyAIController : public AAIController
{
      GENERATED_BODY()

public:
      AEnemyAIController();

protected:
      // 적 캐릭터를 조종하기 시작할 때 호출
      virtual void OnPossess(APawn* InPawn) override;

private:
      // 현재 추적 중인 플레이어 Pawn
      UPROPERTY()
      APawn* TargetPlayer;

      // 현재 조종 중인 적 캐릭터
      UPROPERTY()
      class AEnemyCharacter* ControlledEnemy;

      // 주기적으로 플레이어를 찾아 Blackboard에 갱신하기 위한 타이머
      FTimerHandle FindPlayerTimerHandle;

      // 현재 월드의 플레이어 Pawn을 찾음
      void FindPlayer();

      // 감지 범위에 따라 Blackboard의 TargetActor를 설정하거나 제거
      void SetTargetPlayer();
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;EnemyAIController.cpp&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RunBehaviorTree()를 사용하여 적 캐릭터에 지정된 Behavior Tree를 실행하고,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SetValueAsObject()를 사용하여 Blackboard에 플레이어를 TargetActor로 저장했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 Behavior Tree는 플레이어를 기준으로 추적, 공격 등 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;상황을 판단하고&lt;span&gt; 실행하게 하였다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779293324703&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// EnemyAIController.cpp

#include &quot;EnemyAIController.h&quot;
#include &quot;EnemyCharacter.h&quot;
#include &quot;Kismet/GameplayStatics.h&quot;
#include &quot;BehaviorTree/BehaviorTree.h&quot;
#include &quot;BehaviorTree/BlackboardComponent.h&quot;

// AIController 기본값 설정
AEnemyAIController::AEnemyAIController()
{
    // Behavior Tree가 판단을 담당하므로 Tick 비활성화
    PrimaryActorTick.bCanEverTick = false;

    TargetPlayer = nullptr;
    ControlledEnemy = nullptr;
}

// 적 캐릭터를 조종하기 시작할 때 호출
void AEnemyAIController::OnPossess(APawn* InPawn)
{
    Super::OnPossess(InPawn);

    // 현재 조종할 적 캐릭터 저장
    ControlledEnemy = Cast&amp;lt;AEnemyCharacter&amp;gt;(InPawn);
    if (!ControlledEnemy)
    {
        return;
    }

    // 적 캐릭터에 Behavior Tree가 없으면 실행 불가
    if (!ControlledEnemy-&amp;gt;BehaviorTree)
    {
        UE_LOG(LogTemp, Warning, TEXT(&quot;BehaviorTree is null&quot;));
        return;
    }

    // 적 캐릭터에 지정된 Behavior Tree 실행
    RunBehaviorTree(ControlledEnemy-&amp;gt;BehaviorTree);

    // 일정 시간 뒤 플레이어를 찾아 Blackboard에 저장
    GetWorldTimerManager().SetTimer(
        FindPlayerTimerHandle,
        this,
        &amp;amp;AEnemyAIController::SetTargetPlayer,
        0.2f,
        false
    );
}

// 현재 월드의 플레이어 Pawn 찾기
void AEnemyAIController::FindPlayer()
{
    TargetPlayer = UGameplayStatics::GetPlayerPawn(this, 0);
}

// Blackboard에 TargetActor 설정
void AEnemyAIController::SetTargetPlayer()
{
    FindPlayer();

    UE_LOG(LogTemp, Warning, TEXT(&quot;TargetPlayer: %s&quot;), *GetNameSafe(TargetPlayer));

    UBlackboardComponent* BB = GetBlackboardComponent();
    if (!BB)
    {
        UE_LOG(LogTemp, Warning, TEXT(&quot;BlackboardComponent is null&quot;));
        return;
    }

    if (!TargetPlayer)
    {
        UE_LOG(LogTemp, Warning, TEXT(&quot;TargetPlayer is still None&quot;));
        return;
    }

    // Behavior Tree에서 사용할 TargetActor 저장
    BB-&amp;gt;SetValueAsObject(TEXT(&quot;TargetActor&quot;), TargetPlayer);

    UE_LOG(LogTemp, Warning, TEXT(&quot;Blackboard TargetActor Set: %s&quot;),
        *GetNameSafe(TargetPlayer));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;EnemyCharacter.h&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Behavior Tree를 사용하기 위해 변수를 추가하였고,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UGameplayStatics::ApplyDamage()와 연결하기 위해 TakeDamage()를 override했다.&lt;/p&gt;
&lt;pre id=&quot;code_1779293833090&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// EnemyCharacter.h

#pragma once

#include &quot;CoreMinimal.h&quot;
#include &quot;GameFramework/Character.h&quot;
#include &quot;EnemyCharacter.generated.h&quot;

class UBehaviorTree;

UCLASS()
class CH3_PROJECT_API AEnemyCharacter : public ACharacter
{
    GENERATED_BODY()

public:
    AEnemyCharacter();

    // 최대 HP
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Enemy|Stat&quot;)
    float MaxHP;

    // 현재 HP
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = &quot;Enemy|Stat&quot;)
    float CurrentHP;

    // 방어력
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Enemy|Stat&quot;)
    float Defense;

    // 처치 시 획득 점수
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Enemy|Reward&quot;)
    int32 ScoreValue;

    // 공격 데미지
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Enemy|Attack&quot;)
    float AttackDamage;

    // 플레이어 감지 범위
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Enemy|AI&quot;)
    float DetectionRange;

    // 공격 가능 범위
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Enemy|AI&quot;)
    float AttackRange;

    // AIController가 실행할 Behavior Tree
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = &quot;Enemy|AI&quot;)
    UBehaviorTree* BehaviorTree;

    // 공격 쿨타임
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Enemy|Attack&quot;)
    float AttackCooldown;

    // Unreal 기본 데미지 시스템으로 받은 데미지를 HP에 반영
    virtual float TakeDamage(
        float DamageAmount,
        struct FDamageEvent const&amp;amp; DamageEvent,
        AController* EventInstigator,
        AActor* DamageCauser) override;

    // 방어력 계산 후 실제 HP를 감소시키는 함수
    UFUNCTION(BlueprintCallable)
    void TakeDamageFromEnemy(float Damage);

    // 현재 적이 사망 상태인지 확인
    bool IsDead() const;

protected:
    // 게임 시작 시 초기화
    virtual void BeginPlay() override;

    // 사망 처리
    void Die();

private:
    // 중복 사망 처리를 막기 위한 상태값
    bool bIsDead;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;EnemyCharacter.cpp&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 적이 죽으면 Destroy()로 바로 제거 하였는데,&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;체력이 0이 되면 이동과 충돌을 비활성화 시키고, 2초 뒤에 제거 되도록 수정하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1779293859655&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// EnemyCharacter.cpp

#include &quot;EnemyCharacter.h&quot;
#include &quot;CH3_Project/ShooterGameMode.h&quot;
#include &quot;Kismet/GameplayStatics.h&quot;
#include &quot;Components/CapsuleComponent.h&quot;
#include &quot;GameFramework/CharacterMovementComponent.h&quot;

// 적 기본 스탯 설정
AEnemyCharacter::AEnemyCharacter()
{
    PrimaryActorTick.bCanEverTick = false;

    MaxHP = 100.f;
    CurrentHP = MaxHP;
    Defense = 0.f;
    ScoreValue = 1;
    AttackDamage = 10.f;
    DetectionRange = 1200.f;
    AttackRange = 150.f;
    AttackCooldown = 1.5f;
    bIsDead = false;
}

// 시작 시 현재 HP를 최대 HP로 설정
void AEnemyCharacter::BeginPlay()
{
    Super::BeginPlay();

    CurrentHP = MaxHP;
}

// ApplyDamage로 들어온 데미지를 적 체력 시스템에 반영
float AEnemyCharacter::TakeDamage(
    float DamageAmount,
    FDamageEvent const&amp;amp; DamageEvent,
    AController* EventInstigator,
    AActor* DamageCauser)
{
    const float ActualDamage = Super::TakeDamage(
        DamageAmount,
        DamageEvent,
        EventInstigator,
        DamageCauser
    );

    // 기존 데미지 처리 함수로 연결
    TakeDamageFromEnemy(DamageAmount);

    return ActualDamage;
}

// 받은 데미지에서 방어력을 뺀 후 HP 감소
void AEnemyCharacter::TakeDamageFromEnemy(float Damage)
{
    if (bIsDead)
    {
        return;
    }

    const float FinalDamage = FMath::Max(Damage - Defense, 1.f);
    CurrentHP -= FinalDamage;

    if (CurrentHP &amp;lt;= 0.f)
    {
        Die();
    }
}

// 현재 사망 상태 반환
bool AEnemyCharacter::IsDead() const
{
    return bIsDead;
}

// 점수 지급 후 액터 제거
void AEnemyCharacter::Die()
{
    if (bIsDead)
    {
        return;
    }

    bIsDead = true;

    AShooterGameMode* GM = Cast&amp;lt;AShooterGameMode&amp;gt;(UGameplayStatics::GetGameMode(this));
    if (GM)
    {
        GM-&amp;gt;AddScore(ScoreValue);
    }

    // 사망 후 이동과 충돌 비활성화
    GetCharacterMovement()-&amp;gt;DisableMovement();
    GetCapsuleComponent()-&amp;gt;SetCollisionEnabled(ECollisionEnabled::NoCollision);

    // 2초 후 제거
    SetLifeSpan(2.0f);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;BTDecorator_IsInAttackRange.h&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;플레이어가 공격 범위 안에 있는지 확인 후,&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;범위 안에 있으면 BTTask_AttackTarget이 실행할 수 있게 해주는 역할이다.&lt;/p&gt;
&lt;pre id=&quot;code_1779294706784&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// BTDecorator_IsInAttackRange.h

#pragma once

#include &quot;CoreMinimal.h&quot;
#include &quot;BehaviorTree/BTDecorator.h&quot;
#include &quot;BTDecorator_IsInAttackRange.generated.h&quot;

UCLASS()
class CH3_PROJECT_API UBTDecorator_IsInAttackRange : public UBTDecorator
{
    GENERATED_BODY()

public:
    UBTDecorator_IsInAttackRange();

protected:
    // Behavior Tree가 이 Decorator 조건을 검사할 때 호출
    virtual bool CalculateRawConditionValue(
        UBehaviorTreeComponent&amp;amp; OwnerComp,
        uint8* NodeMemory
    ) const override;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;BTDecorator_IsInAttackRange.cpp&lt;/p&gt;
&lt;pre id=&quot;code_1779294749492&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const float Distance = FVector::Dist(
    Enemy-&amp;gt;GetActorLocation(),
    TargetActor-&amp;gt;GetActorLocation()
);

return Distance &amp;lt;= Enemy-&amp;gt;AttackRange;&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;위 코드가 true 일때만 공격 Task를 실행할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1779294791509&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// BTDecorator_IsInAttackRange.cpp

#include &quot;AI/BTDecorator_IsInAttackRange.h&quot;
#include &quot;AI/EnemyCharacter.h&quot;
#include &quot;AIController.h&quot;
#include &quot;BehaviorTree/BlackboardComponent.h&quot;

// Behavior Tree에서 보일 노드 이름
UBTDecorator_IsInAttackRange::UBTDecorator_IsInAttackRange()
{
    NodeName = TEXT(&quot;Is In Attack Range&quot;);
}

// TargetActor가 적의 공격 범위 안에 있는지 확인
bool UBTDecorator_IsInAttackRange::CalculateRawConditionValue(
    UBehaviorTreeComponent&amp;amp; OwnerComp,
    uint8* NodeMemory
) const
{
    // 현재 Behavior Tree를 실행 중인 AIController 가져오기
    const AAIController* AIController = OwnerComp.GetAIOwner();
    if (!AIController)
    {
        return false;
    }

    // AIController가 조종 중인 Pawn을 EnemyCharacter로 변환
    const AEnemyCharacter* Enemy = Cast&amp;lt;AEnemyCharacter&amp;gt;(AIController-&amp;gt;GetPawn());
    if (!Enemy || Enemy-&amp;gt;IsDead())
    {
        return false;
    }

    // Blackboard에서 TargetActor 가져오기
    const UBlackboardComponent* BlackboardComp = OwnerComp.GetBlackboardComponent();
    if (!BlackboardComp)
    {
        return false;
    }

    const AActor* TargetActor = Cast&amp;lt;AActor&amp;gt;(
        BlackboardComp-&amp;gt;GetValueAsObject(TEXT(&quot;TargetActor&quot;))
    );

    if (!TargetActor)
    {
        return false;
    }

    // 적과 타겟 사이의 거리를 계산
    const float Distance = FVector::Dist(
        Enemy-&amp;gt;GetActorLocation(),
        TargetActor-&amp;gt;GetActorLocation()
    );

    // 공격 범위 안이면 true, 아니면 false
    return Distance &amp;lt;= Enemy-&amp;gt;AttackRange;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;BTTask_AttackTarget.h&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 코드는 Behavior Tree에서 공격 노드가 실행 될 때 호출되게 하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1779293997658&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// BTTask_AttackTarget.h

#pragma once

#include &quot;CoreMinimal.h&quot;
#include &quot;BehaviorTree/BTTaskNode.h&quot;
#include &quot;BTTask_AttackTarget.generated.h&quot;

UCLASS()
class CH3_PROJECT_API UBTTask_AttackTarget : public UBTTaskNode
{
    GENERATED_BODY()

public:
    UBTTask_AttackTarget();

protected:
    // Behavior Tree가 공격 Task를 실행할 때 호출
    virtual EBTNodeResult::Type ExecuteTask(
        UBehaviorTreeComponent&amp;amp; OwnerComp,
        uint8* NodeMemory
    ) override;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;BTTask_AttackTarget.cpp&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;공격 Task가 실행되면 바로 데미지를 적용시켰다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;BTTask_AttackTarget 실행 &amp;gt; Blackboard에서 TargetActor 가져오기 &amp;gt; 이동 정지 &amp;gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;타겟 바라보기 &amp;gt; ApplyDamage() 호출 &amp;gt; 플레이어에게 데미지 적용&lt;/p&gt;
&lt;pre id=&quot;code_1779294384443&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// BTTask_AttackTarget.cpp

#include &quot;AI/BTTask_AttackTarget.h&quot;
#include &quot;AI/EnemyCharacter.h&quot;
#include &quot;AIController.h&quot;
#include &quot;BehaviorTree/BlackboardComponent.h&quot;
#include &quot;Kismet/GameplayStatics.h&quot;

// Behavior Tree에서 보일 노드 이름 설정
UBTTask_AttackTarget::UBTTask_AttackTarget()
{
    NodeName = TEXT(&quot;Attack Target&quot;);
}

// TargetActor에게 데미지를 주는 공격 Task
EBTNodeResult::Type UBTTask_AttackTarget::ExecuteTask(
    UBehaviorTreeComponent&amp;amp; OwnerComp,
    uint8* NodeMemory
)
{
    // 현재 Behavior Tree를 실행 중인 AIController 가져오기
    AAIController* AIController = OwnerComp.GetAIOwner();
    if (!AIController)
    {
        return EBTNodeResult::Failed;
    }

    // AIController가 조종 중인 Pawn을 EnemyCharacter로 변환
    AEnemyCharacter* Enemy = Cast&amp;lt;AEnemyCharacter&amp;gt;(AIController-&amp;gt;GetPawn());
    if (!Enemy || Enemy-&amp;gt;IsDead())
    {
        return EBTNodeResult::Failed;
    }

    // Blackboard에서 TargetActor 가져오기
    UBlackboardComponent* BlackboardComp = OwnerComp.GetBlackboardComponent();
    if (!BlackboardComp)
    {
        return EBTNodeResult::Failed;
    }

    AActor* TargetActor = Cast&amp;lt;AActor&amp;gt;(
        BlackboardComp-&amp;gt;GetValueAsObject(TEXT(&quot;TargetActor&quot;))
    );

    if (!TargetActor)
    {
        return EBTNodeResult::Failed;
    }

    // 공격 중에는 이동을 멈추고 타겟을 바라보게 함
    AIController-&amp;gt;StopMovement();
    AIController-&amp;gt;SetFocus(TargetActor);

    // Unreal 기본 데미지 시스템으로 플레이어에게 데미지 적용
    UGameplayStatics::ApplyDamage(
        TargetActor,
        Enemy-&amp;gt;AttackDamage,
        AIController,
        Enemy,
        nullptr
    );

    UE_LOG(
        LogTemp,
        Warning,
        TEXT(&quot;Enemy BT Attack: %.1f Damage&quot;),
        Enemy-&amp;gt;AttackDamage
    );

    // Task 성공 처리
    return EBTNodeResult::Succeeded;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;변경 후 구조&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Behavior Tree를 사용하면서 구조가 아래처럼 바뀌었다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;EnemyAIController&lt;/b&gt; &lt;br /&gt;&amp;nbsp;- Behavior Tree 실행 &lt;br /&gt;&amp;nbsp;- Blackboard에 TargetActor 설정 &lt;br /&gt;&lt;br /&gt;&lt;b&gt;EnemyCharacter&lt;/b&gt; &lt;br /&gt;&amp;nbsp;- Behavior Tree asset 보유 &lt;br /&gt;&amp;nbsp;- ApplyDamage를 TakeDamage로 받아 HP 처리 &lt;br /&gt;&amp;nbsp;- 사망 시 이동/충돌 비활성화 후 제거 &lt;br /&gt;&lt;br /&gt;&lt;b&gt;BTDecorator_IsInAttackRange&lt;/b&gt; &lt;br /&gt;&amp;nbsp;- TargetActor가 공격 범위 안에 있는지 검사&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;BTTask_AttackTarget&lt;/b&gt; &lt;br /&gt;&amp;nbsp;- TargetActor에게 데미지 적용&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;1.&amp;nbsp;EnemyAIController가&amp;nbsp;EnemyCharacter를&amp;nbsp;Possess &lt;br /&gt;2.&amp;nbsp;EnemyCharacter에&amp;nbsp;설정된&amp;nbsp;Behavior&amp;nbsp;Tree&amp;nbsp;실행 &lt;br /&gt;3.&amp;nbsp;플레이어&amp;nbsp;Pawn&amp;nbsp;탐색 &lt;br /&gt;4.&amp;nbsp;Blackboard에&amp;nbsp;TargetActor&amp;nbsp;저장 &lt;br /&gt;5.&amp;nbsp;Behavior&amp;nbsp;Tree가&amp;nbsp;TargetActor를&amp;nbsp;기준으로&amp;nbsp;이동&amp;nbsp;/&amp;nbsp;공격&amp;nbsp;판단 &lt;br /&gt;6.&amp;nbsp;BTDecorator_IsInAttackRange가&amp;nbsp;공격&amp;nbsp;거리&amp;nbsp;검사 &lt;br /&gt;7.&amp;nbsp;공격&amp;nbsp;범위&amp;nbsp;안이면&amp;nbsp;BTTask_AttackTarget&amp;nbsp;실행 &lt;br /&gt;8.&amp;nbsp;ApplyDamage()로&amp;nbsp;플레이어에게&amp;nbsp;데미지&amp;nbsp;적용&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 단계에서는 EnemyAIController가 직접 처리하던 AI 판단을 Behavior Tree와 Blackboard 기반 구조로 분리하였다.&lt;br /&gt;근데 아직 TargetActor 갱신이 한 번만 실행되고, &lt;span style=&quot;letter-spacing: 0px;&quot;&gt;공격 Task에서 즉시 데미지를 적용하는 구조라 이후 수정을 할 예정이다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>Unreal C++</category>
      <author>hyunjunstar</author>
      <guid isPermaLink="true">https://hyunjunstar.tistory.com/99</guid>
      <comments>https://hyunjunstar.tistory.com/99#entry99comment</comments>
      <pubDate>Wed, 20 May 2026 23:55:12 +0900</pubDate>
    </item>
    <item>
      <title>Unreal C++ 싱글플레이 FPS 슈터 게임 팀 프로젝트 적 AI - 1</title>
      <link>https://hyunjunstar.tistory.com/97</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;AI 구조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;적 AI에서 아래 기능들을 구현 하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;- 적 캐릭터 구현 &lt;br /&gt;&amp;nbsp;- 체력/방어력 시스템&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;- 피격/사망 처리 &lt;br /&gt;&amp;nbsp;- 플레이어 탐지 &lt;br /&gt;&amp;nbsp;- 플레이어 추적 &lt;br /&gt;&amp;nbsp;- 공격 범위 내 공격 &lt;br /&gt;&amp;nbsp;- NavMesh 기반 경로 탐색 &lt;br /&gt;&amp;nbsp;- 장애물 회피&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;AI 폴더 구조&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;ig_0c6a6b13e7e572cc016a0c526fd74481918992628cfa2f696a.png&quot; data-origin-width=&quot;1672&quot; data-origin-height=&quot;941&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/LREZn/dJMcaiwBEN2/u0SOaAusko3M3LFNH72LP0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/LREZn/dJMcaiwBEN2/u0SOaAusko3M3LFNH72LP0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/LREZn/dJMcaiwBEN2/u0SOaAusko3M3LFNH72LP0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FLREZn%2FdJMcaiwBEN2%2Fu0SOaAusko3M3LFNH72LP0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1672&quot; height=&quot;941&quot; data-filename=&quot;ig_0c6a6b13e7e572cc016a0c526fd74481918992628cfa2f696a.png&quot; data-origin-width=&quot;1672&quot; data-origin-height=&quot;941&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;구현한 코드&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맨 처음 구현했던 코드는 EnemyAIController가 모든 판단을 직접 처리하게끔 구현하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플레이어와의 거리를 매 프레임마다 계산 후 거리에 따라 상태를 변경하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;EnemyAIController.h&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;enum을 사용해서 적이 현재 어떤 상태인지를 구분하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1779202778468&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// EnemyAIController.h

#pragma once

#include &quot;CoreMinimal.h&quot;
#include &quot;AIController.h&quot;
#include &quot;EnemyAIController.generated.h&quot;

// 적 AI가 가질 수 있는 상태
UENUM(BlueprintType)
enum class EEnemyAIState : uint8
{
    Idle,   // 대기
    Chase,  // 추적
    Attack, // 공격
    Dead    // 사망
};

UCLASS()
class CH3_PROJECT_API AEnemyAIController : public AAIController
{
    GENERATED_BODY()

public:
    AEnemyAIController();

protected:
    // 적 캐릭터를 조종하기 시작할 때 호출
    virtual void OnPossess(APawn* InPawn) override;

    // 매 프레임 AI 판단을 실행
    virtual void Tick(float DeltaTime) override;

private:
    // 추적할 플레이어
    UPROPERTY()
    APawn* TargetPlayer;

    // 현재 조종 중인 적 캐릭터
    UPROPERTY()
    class AEnemyCharacter* ControlledEnemy;

    // 현재 AI 상태
    EEnemyAIState CurrentState;

    // 마지막 공격 시간
    float LastAttackTime;

    // 플레이어 찾기
    void FindPlayer();

    // AI 상태 갱신
    void UpdateAI();

    // 상태 변경
    void ChangeState(EEnemyAIState NewState);

    // 공격 가능 여부 확인
    bool CanAttack() const;

    // 공격 실행
    void PerformAttack();
};&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;EnemyAIController.cpp&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;UpdateAI()를 사용하여 매 프레임마다 플레이어와의 거리를 계산 후 거리에 따라 상태를 변경하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1779202804629&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// EnemyAIController.cpp

#include &quot;EnemyAIController.h&quot;
#include &quot;EnemyCharacter.h&quot;
#include &quot;Kismet/GameplayStatics.h&quot;

AEnemyAIController::AEnemyAIController()
{
    // Tick에서 매 프레임 AI를 갱신하기 위해 활성화
    PrimaryActorTick.bCanEverTick = true;

    TargetPlayer = nullptr;
    ControlledEnemy = nullptr;
    CurrentState = EEnemyAIState::Idle;
    LastAttackTime = 0.f;
}

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

    // 조종할 Pawn을 EnemyCharacter로 캐스팅
    ControlledEnemy = Cast&amp;lt;AEnemyCharacter&amp;gt;(InPawn);

    // 플레이어 참조 저장
    FindPlayer();
}

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

    // 매 프레임 AI 판단 실행
    UpdateAI();
}

void AEnemyAIController::FindPlayer()
{
    // 0번 플레이어 Pawn 찾기
    TargetPlayer = UGameplayStatics::GetPlayerPawn(this, 0);
}

void AEnemyAIController::UpdateAI()
{
    // 조종 중인 적이 없으면 중단
    if (!ControlledEnemy)
    {
        return;
    }

    // 플레이어가 없으면 다시 찾기
    if (!TargetPlayer)
    {
        FindPlayer();
        return;
    }

    // 적이 죽었으면 Dead 상태로 변경
    if (ControlledEnemy-&amp;gt;IsDead())
    {
        ChangeState(EEnemyAIState::Dead);
        StopMovement();
        return;
    }

    // 적과 플레이어 사이의 거리 계산
    const float Distance = FVector::Dist(
        ControlledEnemy-&amp;gt;GetActorLocation(),
        TargetPlayer-&amp;gt;GetActorLocation()
    );

    // 공격 범위 안이면 공격
    if (Distance &amp;lt;= ControlledEnemy-&amp;gt;AttackRange)
    {
        ChangeState(EEnemyAIState::Attack);
        StopMovement();
        PerformAttack();
    }
    // 감지 범위 안이면 추적
    else if (Distance &amp;lt;= ControlledEnemy-&amp;gt;DetectionRange)
    {
        ChangeState(EEnemyAIState::Chase);
        MoveToActor(TargetPlayer);
    }
    // 감지 범위 밖이면 대기
    else
    {
        ChangeState(EEnemyAIState::Idle);
        StopMovement();
    }
}

void AEnemyAIController::ChangeState(EEnemyAIState NewState)
{
    // 같은 상태면 변경하지 않음
    if (CurrentState == NewState)
    {
        return;
    }

    CurrentState = NewState;
}

bool AEnemyAIController::CanAttack() const
{
    if (!ControlledEnemy || !GetWorld())
    {
        return false;
    }

    // 마지막 공격 이후 쿨타임이 지났는지 확인
    const float CurrentTime = GetWorld()-&amp;gt;GetTimeSeconds();
    return CurrentTime - LastAttackTime &amp;gt;= ControlledEnemy-&amp;gt;AttackCooldown;
}

void AEnemyAIController::PerformAttack()
{
    // 공격 쿨타임이 안 지났으면 공격하지 않음
    if (!CanAttack() || !GetWorld())
    {
        return;
    }

    // 마지막 공격 시간 갱신
    LastAttackTime = GetWorld()-&amp;gt;GetTimeSeconds();

    // 초기 버전에서는 공격 로그만 출력
    UE_LOG(LogTemp, Warning, TEXT(&quot;Enemy Attack&quot;));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;EnemyCharacter.h&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;적 캐릭터의 기본 스탯 값들을 설정하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1779202825929&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// EnemyCharacter.h

#pragma once

#include &quot;CoreMinimal.h&quot;
#include &quot;GameFramework/Character.h&quot;
#include &quot;EnemyCharacter.generated.h&quot;

UCLASS()
class CH3_PROJECT_API AEnemyCharacter : public ACharacter
{
    GENERATED_BODY()

public:
    AEnemyCharacter();

    // 최대 체력
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Enemy|Stat&quot;)
    float MaxHP;

    // 현재 체력
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = &quot;Enemy|Stat&quot;)
    float CurrentHP;

    // 방어력
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Enemy|Stat&quot;)
    float Defense;

    // 처치 시 지급 점수
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Enemy|Reward&quot;)
    int32 ScoreValue;

    // 공격 데미지
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Enemy|Attack&quot;)
    float AttackDamage;

    // 플레이어 감지 범위
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Enemy|AI&quot;)
    float DetectionRange;

    // 공격 가능 범위
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Enemy|AI&quot;)
    float AttackRange;

    // 공격 쿨타임
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Enemy|Attack&quot;)
    float AttackCooldown;

    // 데미지 처리 함수
    UFUNCTION(BlueprintCallable)
    void TakeDamageFromEnemy(float Damage);

    // 사망 여부 반환
    bool IsDead() const;

protected:
    // 게임 시작 시 호출
    virtual void BeginPlay() override;

    // 사망 처리
    void Die();

private:
    // 중복 사망 처리를 막기 위한 변수
    bool bIsDead;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;EnemyCharacter.cpp&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;적 캐릭터의 기본 스탯을 초기화 및 각종 상태 처리 구현,&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;적 캐릭터 사망시 GameMode에 점수를 지급 한 후 Destroy()로 제거 하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1779202840416&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// EnemyCharacter.cpp

#include &quot;EnemyCharacter.h&quot;
#include &quot;CH3_Project/ShooterGameMode.h&quot;
#include &quot;Kismet/GameplayStatics.h&quot;

AEnemyCharacter::AEnemyCharacter()
{
    // 적 캐릭터 자체는 Tick을 사용하지 않음
    PrimaryActorTick.bCanEverTick = false;

    // 기본 스탯 설정
    MaxHP = 100.f;
    CurrentHP = MaxHP;
    Defense = 0.f;
    ScoreValue = 1;
    AttackDamage = 10.f;
    DetectionRange = 1200.f;
    AttackRange = 150.f;
    AttackCooldown = 1.5f;
    bIsDead = false;
}

void AEnemyCharacter::BeginPlay()
{
    Super::BeginPlay();

    // 게임 시작 시 체력을 최대 체력으로 초기화
    CurrentHP = MaxHP;
}

void AEnemyCharacter::TakeDamageFromEnemy(float Damage)
{
    // 이미 죽었으면 데미지 무시
    if (bIsDead)
    {
        return;
    }

    // 방어력을 적용한 최종 데미지 계산
    const float FinalDamage = FMath::Max(Damage - Defense, 1.f);
    CurrentHP -= FinalDamage;

    // 체력이 0 이하가 되면 사망 처리
    if (CurrentHP &amp;lt;= 0.f)
    {
        Die();
    }
}

bool AEnemyCharacter::IsDead() const
{
    return bIsDead;
}

void AEnemyCharacter::Die()
{
    // 중복 사망 처리 방지
    if (bIsDead)
    {
        return;
    }

    bIsDead = true;

    // GameMode에 점수 추가
    AShooterGameMode* GM = Cast&amp;lt;AShooterGameMode&amp;gt;(UGameplayStatics::GetGameMode(this));
    if (GM)
    {
        GM-&amp;gt;AddScore(ScoreValue);
    }

    // 초기 버전에서는 죽으면 바로 제거
    Destroy();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;CH3_Project.Build.cs&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;AI 사용을 위해 AIModule, NavigationSystem, GameplayTasks 추가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1779368319330&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// CH3_Project.Build.cs
// Copyright Epic Games, Inc. All Rights Reserved.

using UnrealBuildTool;

public class CH3_Project : ModuleRules
{
	public CH3_Project(ReadOnlyTargetRules Target) : base(Target)
	{
		PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
	
		PublicDependencyModuleNames.AddRange(new string[] { &quot;Core&quot;, 
			&quot;CoreUObject&quot;, 
			&quot;Engine&quot;, 
			&quot;InputCore&quot;, 
			&quot;EnhancedInput&quot;,
			&quot;UMG&quot;,
			&quot;AIModule&quot;,
            &quot;NavigationSystem&quot;,
            &quot;GameplayTasks&quot; });

		PrivateDependencyModuleNames.AddRange(new string[] {  });
        PublicIncludePaths.AddRange(new string[] {&quot;CH3_Project&quot;});
        // Uncomment if you are using Slate UI
        // PrivateDependencyModuleNames.AddRange(new string[] { &quot;Slate&quot;, &quot;SlateCore&quot; });

        // Uncomment if you are using online features
        // PrivateDependencyModuleNames.Add(&quot;OnlineSubsystem&quot;);

        // To include OnlineSubsystemSteam, add it to the plugins section in your uproject file with the Enabled attribute set to true
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하자면 위 코드들은 아래처럼 설계 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. EnemyAIController가 EnemyCharacter를 Possess &lt;br /&gt;2. 플레이어 Pawn 찾기 &lt;br /&gt;3. Tick에서 매 프레임 UpdateAI 실행 &lt;br /&gt;4. 플레이어와 적 사이 거리 계산 &lt;br /&gt;5. 공격 범위 안이면 Attack &lt;br /&gt;6. 감지 범위 안이면 Chase &lt;br /&gt;7. 감지 범위 밖이면 Idle &lt;br /&gt;8. 적 체력이 0 이하가 되면 Dead &lt;br /&gt;9. 점수 지급 후 Destroy&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;EnemyAIController의 역할&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플레이어 탐색 &amp;gt; 거리 계산 &amp;gt; 상태 변경 &amp;gt; 이동 &amp;gt; 공격 쿨타임 관리 &amp;gt; 공격&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;EnemyCharacter&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 스탯 관리 &amp;gt; 방어력 적용 &amp;gt; 공격 및 감지 범위 값 적용 &amp;gt; 사망 처리 &amp;gt; 점수 지급&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 초기 구현은 EnemyAIController 중심으로 구현 하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Unreal C++</category>
      <author>hyunjunstar</author>
      <guid isPermaLink="true">https://hyunjunstar.tistory.com/97</guid>
      <comments>https://hyunjunstar.tistory.com/97#entry97comment</comments>
      <pubDate>Tue, 19 May 2026 20:50:59 +0900</pubDate>
    </item>
    <item>
      <title>Unreal C++ 게임 루프 및 UI 설계 후 코인 획득 게임 구현 트러블슈팅</title>
      <link>https://hyunjunstar.tistory.com/96</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 맵 이름 변경 후 Start 버튼이 동작하지 않는 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는&amp;nbsp;기존&amp;nbsp;맵&amp;nbsp;이름이&amp;nbsp;BasicLevel이었는데,&amp;nbsp;과제&amp;nbsp;진행&amp;nbsp;중&amp;nbsp;맵&amp;nbsp;이름을&amp;nbsp;Level1로&amp;nbsp;변경하였다. &lt;br /&gt;하지만&amp;nbsp;게임&amp;nbsp;시작&amp;nbsp;버튼을&amp;nbsp;눌러도&amp;nbsp;다음&amp;nbsp;레벨로&amp;nbsp;이동하지&amp;nbsp;않고,&amp;nbsp;로그에&amp;nbsp;Invalid&amp;nbsp;URL&amp;nbsp;관련&amp;nbsp;오류가&amp;nbsp;발생하였다. &lt;br /&gt;&lt;br /&gt;원인을&amp;nbsp;확인해보니&amp;nbsp;StartGame()&amp;nbsp;함수와&amp;nbsp;레벨&amp;nbsp;이동&amp;nbsp;배열&amp;nbsp;쪽에서&amp;nbsp;아직&amp;nbsp;이전&amp;nbsp;맵&amp;nbsp;이름을&amp;nbsp;참조하고&amp;nbsp;있었다. &lt;/p&gt;
&lt;pre id=&quot;code_1777552233396&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// SpartaPlayerController

UGameplayStatics::OpenLevel(GetWorld(), FName(&quot;BasicLevel&quot;)); 
// 위 코드에서 맵 이름을 Level1로 변경했으니 코드에서도 동일하게 수정해주었다.

UGameplayStatics::OpenLevel(GetWorld(), FName(&quot;Level1&quot;));

// 그리고 GameState에서 사용하는 LevelMapNames 배열도 에디터에서 Level1, Level2, Level3로 다시 확인해주었다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 통해 맵 이름을 변경할 때는 에디터의 맵 파일명뿐 아니라,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드와 블루프린트에서 참조하는 이름도 같이 확인해야 한다는 것을 알게 되었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 지뢰 아이템이 바로 사라져서 폭발 처리가 되지 않는 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는&amp;nbsp;MineItem에서도&amp;nbsp;부모&amp;nbsp;클래스인&amp;nbsp;BaseItem의&amp;nbsp;ActivateItem()을&amp;nbsp;그대로&amp;nbsp;사용하려고&amp;nbsp;했다. &lt;br /&gt;하지만&amp;nbsp;BaseItem::ActivateItem()&amp;nbsp;안에서는&amp;nbsp;파티클과&amp;nbsp;사운드를&amp;nbsp;재생한&amp;nbsp;뒤&amp;nbsp;바로&amp;nbsp;DestroyItem()을&amp;nbsp;호출하고&amp;nbsp;있었다. &lt;/p&gt;
&lt;pre id=&quot;code_1777553167067&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// BaseItem.cpp

void ABaseItem::ActivateItem(AActor* Activator)
{
    // 파티클, 사운드 처리

    DestroyItem();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조를 지뢰 아이템에 그대로 사용하니, 지뢰가 폭발 타이머를 기다리기 전에 액터가 먼저 제거되는 문제가 생겼다. &lt;br /&gt;&lt;br /&gt;그래서 MineItem에서는 Super::ActivateItem()을 호출하지 않고, 지뢰 전용 로직을 따로 구현하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1777553268490&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// MineItem.cpp

void AMineItem::ActivateItem(AActor* Activator)
{
    if (bHasExploded) return;

    bHasExploded = true;

    if (Collision)
    {
        Collision-&amp;gt;SetCollisionEnabled(ECollisionEnabled::NoCollision);
    }

    GetWorld()-&amp;gt;GetTimerManager().SetTimer(
        ExplosionTimerHandle,
        this,
        &amp;amp;AMineItem::Explode,
        ExplosionDelay,
        false
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 수정하니 지뢰가 플레이어와 충돌하면 바로 사라지지 않고, 일정 시간이 지난 뒤 정상적으로 폭발하도록 되었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 웨이브가 넘어갈 때 파티클이 남아있는 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아이템&amp;nbsp;획득이나&amp;nbsp;지뢰&amp;nbsp;폭발&amp;nbsp;시&amp;nbsp;파티클을&amp;nbsp;생성했는데,&amp;nbsp;웨이브가&amp;nbsp;끝나거나&amp;nbsp;레벨이&amp;nbsp;바뀌어도&amp;nbsp;파티클이&amp;nbsp;남아있는&amp;nbsp;문제가&amp;nbsp;있었다. &lt;br /&gt;&lt;br /&gt;처음에는&amp;nbsp;각&amp;nbsp;아이템에서&amp;nbsp;타이머로&amp;nbsp;파티클을&amp;nbsp;제거하려고&amp;nbsp;했지만,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웨이브나 레벨 전환 시점과 파티클 제거 타이밍이 겹치면서 에디터가 강제로 종료되면서 오류가 발생하며 관리가 복잡해졌다. &lt;br /&gt;&lt;br /&gt;그래서&amp;nbsp;생성된&amp;nbsp;파티클을&amp;nbsp;GameState에&amp;nbsp;등록해두고,&amp;nbsp;웨이브나&amp;nbsp;레벨이&amp;nbsp;끝날&amp;nbsp;때&amp;nbsp;한&amp;nbsp;번에&amp;nbsp;정리하는&amp;nbsp;방식으로&amp;nbsp;수정하였다. &lt;/p&gt;
&lt;pre id=&quot;code_1777553778897&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// SpartaGameState.cpp

void ASpartaGameState::RegisterParticle(UParticleSystemComponent* Particle)
{
    if (Particle)
    {
        ActiveParticles.Add(Particle);
    }
}

void ASpartaGameState::ClearActiveParticles()
{
    for (TWeakObjectPtr&amp;lt;UParticleSystemComponent&amp;gt;&amp;amp; WeakParticle : ActiveParticles)
    {
        if (WeakParticle.IsValid())
        {
            WeakParticle-&amp;gt;DeactivateSystem();
            WeakParticle-&amp;gt;DestroyComponent();
        }
    }

    ActiveParticles.Empty();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;그리고&amp;nbsp;EndWave()와&amp;nbsp;EndLevel()에서&amp;nbsp;ClearActiveParticles()를&amp;nbsp;호출하여&amp;nbsp;남아있는&amp;nbsp;파티클을&amp;nbsp;정리하였다. &lt;/p&gt;
&lt;pre id=&quot;code_1777553975620&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// SpartaGameState.cpp

void ASpartaGameState::EndWave()
{
    ClearActiveParticles();

    GetWorldTimerManager().ClearTimer(LevelTimerHandle);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이렇게 수정하니 웨이브 전환 시 화면에 남아있던 파티클들이 깔끔하게 제거되었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;마무리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번&amp;nbsp;과제에서는&amp;nbsp;단순히&amp;nbsp;아이템을&amp;nbsp;스폰하고&amp;nbsp;획득하는&amp;nbsp;기능을&amp;nbsp;넘어서,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임&amp;nbsp;전체&amp;nbsp;흐름을&amp;nbsp;GameState에서&amp;nbsp;관리하는&amp;nbsp;구조를&amp;nbsp;만들어보았다. &lt;br /&gt;&lt;br /&gt;레벨과&amp;nbsp;웨이브&amp;nbsp;진행,&amp;nbsp;제한&amp;nbsp;시간,&amp;nbsp;코인&amp;nbsp;획득&amp;nbsp;조건,&amp;nbsp;다음&amp;nbsp;레벨&amp;nbsp;이동,&amp;nbsp;게임오버&amp;nbsp;처리까지&amp;nbsp;하나의&amp;nbsp;흐름으로&amp;nbsp;연결하면서&amp;nbsp;게임&amp;nbsp;루프가&amp;nbsp;어떻게&amp;nbsp;구성되는지&amp;nbsp;더&amp;nbsp;이해할&amp;nbsp;수&amp;nbsp;있었다. &lt;br /&gt;&lt;br /&gt;또한&amp;nbsp;SpawnVolume과&amp;nbsp;데이터테이블을&amp;nbsp;활용해&amp;nbsp;아이템&amp;nbsp;스폰&amp;nbsp;확률을&amp;nbsp;관리하고,&amp;nbsp;레벨이&amp;nbsp;올라갈수록&amp;nbsp;지뢰와&amp;nbsp;회복&amp;nbsp;아이템의&amp;nbsp;난이도가&amp;nbsp;달라지도록&amp;nbsp;구현하면서&amp;nbsp;데이터&amp;nbsp;기반&amp;nbsp;설계의&amp;nbsp;편리함도&amp;nbsp;느낄&amp;nbsp;수&amp;nbsp;있었다. &lt;br /&gt;&lt;br /&gt;UI&amp;nbsp;부분에서는&amp;nbsp;처음에는&amp;nbsp;GameState에서&amp;nbsp;직접&amp;nbsp;HUD&amp;nbsp;텍스트를&amp;nbsp;수정했지만,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;점점&amp;nbsp;코드가&amp;nbsp;복잡해져서&amp;nbsp;SpartaHUDWidget과&amp;nbsp;SpartaMainMenuWidget으로&amp;nbsp;분리하였다. &lt;br /&gt;덕분에&amp;nbsp;게임&amp;nbsp;진행&amp;nbsp;로직과&amp;nbsp;UI&amp;nbsp;표시&amp;nbsp;로직을&amp;nbsp;나누어&amp;nbsp;관리할&amp;nbsp;수&amp;nbsp;있었고,&amp;nbsp;코드&amp;nbsp;구조도&amp;nbsp;더&amp;nbsp;깔끔해졌다. &lt;br /&gt;&lt;br /&gt;작업&amp;nbsp;중&amp;nbsp;맵&amp;nbsp;이름&amp;nbsp;변경으로&amp;nbsp;인한&amp;nbsp;레벨&amp;nbsp;이동&amp;nbsp;오류,&amp;nbsp;지뢰&amp;nbsp;아이템이&amp;nbsp;바로&amp;nbsp;사라지는&amp;nbsp;문제,&amp;nbsp;웨이브&amp;nbsp;전환&amp;nbsp;시&amp;nbsp;파티클이&amp;nbsp;남아&amp;nbsp;충돌하는&amp;nbsp;문제&amp;nbsp;등이&amp;nbsp;있었지만,&amp;nbsp;하나씩&amp;nbsp;원인을&amp;nbsp;찾아&amp;nbsp;수정하면서&amp;nbsp;언리얼에서&amp;nbsp;액터의 생명주기,&amp;nbsp;타이머,&amp;nbsp;위젯&amp;nbsp;관리가&amp;nbsp;얼마나&amp;nbsp;중요한지&amp;nbsp;알게&amp;nbsp;되었다. &lt;br /&gt;&lt;br /&gt;이번&amp;nbsp;과제를&amp;nbsp;통해&amp;nbsp;GameState,&amp;nbsp;PlayerController,&amp;nbsp;Widget,&amp;nbsp;DataTable,&amp;nbsp;Timer가&amp;nbsp;서로&amp;nbsp;어떻게&amp;nbsp;연결되는지&amp;nbsp;직접&amp;nbsp;경험할&amp;nbsp;수&amp;nbsp;있었고,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전보다 게임 구조를 조금 더 이해할 수 있게 된 것 같다.&lt;/p&gt;</description>
      <category>Unreal C++</category>
      <author>hyunjunstar</author>
      <guid isPermaLink="true">https://hyunjunstar.tistory.com/96</guid>
      <comments>https://hyunjunstar.tistory.com/96#entry96comment</comments>
      <pubDate>Thu, 30 Apr 2026 22:10:51 +0900</pubDate>
    </item>
    <item>
      <title>Unreal C++ 게임 루프 및 UI 설계 후 코인 획득 게임 구현</title>
      <link>https://hyunjunstar.tistory.com/95</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;오늘은 Unreal C++ 과제로 기존 프로젝트에 게임 루프와 UI를 재설계 하는 작업을 진행했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GameState에서 레벨과 웨이브 흐름을 관리,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SpawnVolume과 데이터 테이블을 활용해서 아이템들이 확률 기반으로 스폰 되도록 구현 하였으며,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HUD와 메인 메뉴를 별도 위젯 클래스로 분리하여 점수, 시간, 레벨, 웨이브, 게임오버 메뉴 등을 구현하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;게임 루프&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;- GameState에서&amp;nbsp;레벨과&amp;nbsp;웨이브&amp;nbsp;진행&amp;nbsp;관리 &lt;br /&gt;&amp;nbsp;- 각 레벨마다 3개의 웨이브 진행 &lt;br /&gt;&amp;nbsp;- 웨이브별 제한 시간과 아이템 스폰 수 설정 &lt;br /&gt;&amp;nbsp;- 시간이 끝나거나 코인을 모두 획득하면 다음 웨이브로 이동 &lt;br /&gt;&amp;nbsp;- 모든 웨이브가 끝나면 다음 레벨로 이동 &lt;br /&gt;&amp;nbsp;- 마지막 레벨 종료 시 게임오버 메뉴 출력&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;아이템 스폰&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;- SpawnVolume을 활용한 랜덤 위치 스폰&lt;br /&gt;&amp;nbsp;- 데이터테이블 기반 아이템 확률 관리&lt;br /&gt;&amp;nbsp;- 코인, 회복 아이템, 지뢰 아이템 스폰&lt;br /&gt;&amp;nbsp;- 스폰된 아이템에 현재 레벨 난이도 적용&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;아이템&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;- BaseItem에서 공통 충돌, 파티클, 사운드 처리&lt;br /&gt;&amp;nbsp;- 코인 아이템 획득 시 점수 증가&lt;br /&gt;&amp;nbsp;- 회복 아이템은 레벨이 올라갈수록 회복량 감소&lt;br /&gt;&amp;nbsp;- 지뢰 아이템은 레벨이 올라갈수록 폭발 시간 감소 및 데미지 증가&lt;br /&gt;&amp;nbsp;- 웨이브 전환 시 남은 파티클 정리&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;UI&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;- SpartaHUDWidget으로 HUD 관리&lt;br /&gt;&amp;nbsp;- 점수, 시간, 레벨, 웨이브 표시&lt;br /&gt;&amp;nbsp;- 캐릭터 머리 위에 체력 텍스트와 HP바 표시&lt;br /&gt;&amp;nbsp;- SpartaMainMenuWidget으로 메인 메뉴와 게임오버 메뉴 관리&lt;br /&gt;&amp;nbsp;- 시작, 재시작, 메인 메뉴로 돌아가기, 종료 버튼 기능 연결&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. GameState - 웨이브, 레벨 시작 종료 및 파티클 정리&lt;/h4&gt;
&lt;pre id=&quot;code_1777472368398&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// SpartaGameState.h

#pragma once

#include &quot;CoreMinimal.h&quot;
#include &quot;GameFramework/GameState.h&quot;
#include &quot;SpartaGameState.generated.h&quot;

class UParticleSystemComponent; // 파티클 컴포넌트 클래스 전방 선언

UCLASS()
class SPARTA_API ASpartaGameState : public AGameState
{
    GENERATED_BODY()

public:
    ASpartaGameState();

    virtual void BeginPlay() override;

    // 현재 점수 반환
    UFUNCTION(BlueprintPure, Category = &quot;Score&quot;)
    int32 GetScore() const;

    // 점수 추가
    UFUNCTION(BlueprintCallable, Category = &quot;Score&quot;)
    void AddScore(int32 Amount);

    // 게임 오버 메뉴 출력
    UFUNCTION(BlueprintCallable, Category = &quot;Level&quot;)
    void OnGameOver();

    // 레벨 시작
    void StartLevel();

    // 레벨 시간 종료 처리
    void OnLevelTimeUp();

    // 코인 획득 처리
    void OnCoinCollected();

    // 레벨 종료 및 다음 레벨 이동
    void EndLevel();

    // HUD 갱신
    void UpdateHUD();

    // 웨이브 시작
    void StartWave();

    // 웨이브 시간 종료 처리
    void OnWaveTimeUp();

    // 웨이브 종료 및 다음 웨이브 이동
    void EndWave();

    // 파티클 등록
    void RegisterParticle(UParticleSystemComponent* Particle);

public:
    // 현재 레벨 점수
    UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = &quot;Score&quot;)
    int32 Score;

    // 현재 웨이브에서 생성된 코인 수
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = &quot;Coin&quot;)
    int32 SpawnedCoinCount;

    // 현재 웨이브에서 획득한 코인 수
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = &quot;Coin&quot;)
    int32 CollectedCoinCount;

    // 현재 웨이브 제한 시간
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = &quot;Level&quot;)
    float LevelDuration;

    // 현재 레벨 인덱스
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = &quot;Level&quot;)
    int32 CurrentLevelIndex;

    // 전체 레벨
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = &quot;Level&quot;)
    int32 MaxLevels;

    // 레벨 이동에 사용할 맵 이름 배열
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = &quot;Level&quot;)
    TArray&amp;lt;FName&amp;gt; LevelMapNames;

    // 현재 웨이브 인덱스
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = &quot;Wave&quot;)
    int32 CurrentWaveIndex;

    // 한 레벨의 최대 웨이브 수
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = &quot;Wave&quot;)
    int32 MaxWaves;

protected:
    // 현재 웨이브에서 스폰할 아이템 수
    int32 ItemToSpawn;

    // 웨이브 제한 시간 타이머
    FTimerHandle LevelTimerHandle;

    // HUD 갱신 타이머
    FTimerHandle HUDUpdateTimerHandle;

    // 생성된 파티클 목록을 가져옴
    TArray&amp;lt;TWeakObjectPtr&amp;lt;UParticleSystemComponent&amp;gt;&amp;gt; ActiveParticles;

    // 등록된 파티클 제거
    void ClearActiveParticles();

    // 레벨에 따라 아이템 난이도 조절
    void ApplyItemDifficulty(AActor* SpawnedActor);
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1777472377469&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// SpartaGameState.cpp

#include &quot;SpartaGameState.h&quot;
#include &quot;SpartaGameInstance.h&quot;
#include &quot;SpartaPlayerController.h&quot;
#include &quot;SpawnVolume.h&quot;
#include &quot;BaseItem.h&quot;
#include &quot;CoinItem.h&quot;
#include &quot;MineItem.h&quot;
#include &quot;HealingItem.h&quot;
#include &quot;SpartaHUDWidget.h&quot;
#include &quot;SpartaCharacter.h&quot;
#include &quot;Kismet/GameplayStatics.h&quot;
#include &quot;Particles/ParticleSystemComponent.h&quot;

// 게임 정보
ASpartaGameState::ASpartaGameState()
{
    Score = 0;
    
    LevelDuration = 0;
    ItemToSpawn = 0;

    CurrentWaveIndex = 0;
    MaxWaves = 3;
    
    CurrentLevelIndex = 0;
    MaxLevels = 3;
}

// ======================================== // 

// 게임 시작 시 레벨 시작 및 HUD 갱신 타이머 실행
void ASpartaGameState::BeginPlay()
{
    Super::BeginPlay();

    // 현재 레벨 시작
    StartLevel();

    // HUD를 0.1초마다 갱신
    GetWorldTimerManager().SetTimer(
        HUDUpdateTimerHandle,
        this,
        &amp;amp;ASpartaGameState::UpdateHUD,
        0.1f,
        true
    );
}

// ======================================== // 

// 현재 점수 반환
int32 ASpartaGameState::GetScore() const
{
    return Score;
}

// ======================================== // 

// GameInstance에 점수 누적
void ASpartaGameState::AddScore(int32 Amount)
{
    // 현재 GameInstance 정보 가져오기
    if (UGameInstance* GameInstance = GetGameInstance())
    {
        // 프로젝트 전용 GameInstance로 캐스팅
        if (USpartaGameInstance* SpartaGameInstance = Cast&amp;lt;USpartaGameInstance&amp;gt;(GameInstance))
        {
            // 총점수에 점수 누적
            SpartaGameInstance-&amp;gt;AddToScore(Amount);
        }
    }
}

// ======================================== // 

// 레벨 시작 HUD 표시, 현재 레벨 인덱스 불러오기, 1웨이브 시작
void ASpartaGameState::StartLevel()
{
    // 플레이어 컨트롤러 가져오기
    if (APlayerController* PlayerController = GetWorld()-&amp;gt;GetFirstPlayerController())
    {
        // 프로젝트 전용 PlayerController로 캐스팅
        if (ASpartaPlayerController* SpartaPlayerController = Cast&amp;lt;ASpartaPlayerController&amp;gt;(PlayerController))
        {
            // 게임 HUD 표시
            SpartaPlayerController-&amp;gt;ShowGameHUD();
        }
    }

    // GameInstance에서 현재 레벨 인덱스 가져오기
    if (UGameInstance* GameInstance = GetGameInstance())
    {
        if (USpartaGameInstance* SpartaGameInstance = Cast&amp;lt;USpartaGameInstance&amp;gt;(GameInstance))
        {
            CurrentLevelIndex = SpartaGameInstance-&amp;gt;CurrentLevelIndex;
        }
    }

    // 레벨 시작 시 코인 카운트 초기화
    SpawnedCoinCount = 0;
    CollectedCoinCount = 0;

    // 1웨이브부터 시작
    CurrentWaveIndex = 1;

    // 첫 웨이브 시작
    StartWave();
}

// ======================================== // 

// 웨이브 시작: 웨이브별 시간과 아이템 수 설정 후 아이템 스폰
void ASpartaGameState::StartWave()
{
    // 월드에 배치된 SpawnVolume 검색
    TArray&amp;lt;AActor*&amp;gt; FoundVolumes;
    UGameplayStatics::GetAllActorsOfClass(GetWorld(), ASpawnVolume::StaticClass(), FoundVolumes);

    // 웨이브마다 코인 카운트 초기화
    SpawnedCoinCount = 0;
    CollectedCoinCount = 0;

    // 1웨이브 설정
    if (CurrentWaveIndex == 1)
    {
        // 화면에 웨이브 시작 메시지 출력
        if (GEngine)
        {
            GEngine-&amp;gt;AddOnScreenDebugMessage(
                -1,
                2.0f,
                FColor::Green,
                FString::Printf(TEXT(&quot;Wave %d 시작&quot;), CurrentWaveIndex)
            );
        }

        // 제한 시간, 아이템 개수 설정
        LevelDuration = 40.0f;
        ItemToSpawn = 40;

        // 설정된 개수만큼 아이템 스폰
        for (int32 i = 0; i &amp;lt; ItemToSpawn; i++)
        {
            // SpawnVolume이 존재하는지 확인
            if (FoundVolumes.Num() &amp;gt; 0)
            {
                // 첫 번째 SpawnVolume 사용
                ASpawnVolume* SpawnVolume = Cast&amp;lt;ASpawnVolume&amp;gt;(FoundVolumes[0]);
                if (SpawnVolume)
                {
                    // 랜덤 아이템 스폰
                    AActor* SpawnedActor = SpawnVolume-&amp;gt;SpawnRandomItem();

                    // 스폰된 아이템에 레벨 난이도 적용
                    ApplyItemDifficulty(SpawnedActor);

                    // 스폰된 아이템이 코인이면 코인 수 증가
                    if (SpawnedActor &amp;amp;&amp;amp; SpawnedActor-&amp;gt;IsA(ACoinItem::StaticClass()))
                    {
                        SpawnedCoinCount++;
                    }
                }
            }
        }
    }

    // 2웨이브 설정
    else if (CurrentWaveIndex == 2)
    {
        if (GEngine)
        {
            GEngine-&amp;gt;AddOnScreenDebugMessage(
                -1,
                2.0f,
                FColor::Green,
                FString::Printf(TEXT(&quot;Wave %d 시작&quot;), CurrentWaveIndex)
            );
        }

        LevelDuration = 35.0f;
        ItemToSpawn = 45;

        for (int32 i = 0; i &amp;lt; ItemToSpawn; i++)
        {
            if (FoundVolumes.Num() &amp;gt; 0)
            {
                ASpawnVolume* SpawnVolume = Cast&amp;lt;ASpawnVolume&amp;gt;(FoundVolumes[0]);
                if (SpawnVolume)
                {
                    AActor* SpawnedActor = SpawnVolume-&amp;gt;SpawnRandomItem();

                    ApplyItemDifficulty(SpawnedActor);

                    if (SpawnedActor &amp;amp;&amp;amp; SpawnedActor-&amp;gt;IsA(ACoinItem::StaticClass()))
                    {
                        SpawnedCoinCount++;
                    }
                }
            }
        }
    }

    // 3웨이브 설정
    else if (CurrentWaveIndex == 3)
    {
        if (GEngine)
        {
            GEngine-&amp;gt;AddOnScreenDebugMessage(
                -1,
                2.0f,
                FColor::Green,
                FString::Printf(TEXT(&quot;Wave %d 시작&quot;), CurrentWaveIndex)
            );
        }

        LevelDuration = 30.0f;
        ItemToSpawn = 50;

        for (int32 i = 0; i &amp;lt; ItemToSpawn; i++)
        {
            if (FoundVolumes.Num() &amp;gt; 0)
            {
                ASpawnVolume* SpawnVolume = Cast&amp;lt;ASpawnVolume&amp;gt;(FoundVolumes[0]);
                if (SpawnVolume)
                {
                    AActor* SpawnedActor = SpawnVolume-&amp;gt;SpawnRandomItem();

                    ApplyItemDifficulty(SpawnedActor);

                    if (SpawnedActor &amp;amp;&amp;amp; SpawnedActor-&amp;gt;IsA(ACoinItem::StaticClass()))
                    {
                        SpawnedCoinCount++;
                    }
                }
            }
        }
    }

    // 웨이브 제한 시간 타이머 설정
    GetWorldTimerManager().SetTimer(
        LevelTimerHandle,
        this,
        &amp;amp;ASpartaGameState::OnWaveTimeUp,
        LevelDuration,
        false
    );
}

// ======================================== // 

// 웨이브 시간이 끝났을 때 호출
void ASpartaGameState::OnWaveTimeUp()
{
    // 웨이브 종료
    EndWave();
}

// ======================================== // 

// 레벨 시간이 끝났을 때 호출
void ASpartaGameState::OnLevelTimeUp()
{
    // 레벨 종료
    EndLevel();
}

// ======================================== // 

// 코인 획득 시 획득 수를 증가시키고, 모두 획득하면 웨이브 종료
void ASpartaGameState::OnCoinCollected()
{
    // 획득한 코인 수 증가
    CollectedCoinCount++;

    // 현재 코인 획득 상황 로그 출력
    UE_LOG(LogTemp, Warning,
        TEXT(&quot;획득한 코인 : %d / %d&quot;),
        CollectedCoinCount,
        SpawnedCoinCount
    );

    // 모든 코인을 획득하면 웨이브 종료
    if (SpawnedCoinCount &amp;gt; 0 &amp;amp;&amp;amp; CollectedCoinCount &amp;gt;= SpawnedCoinCount)
    {
        EndWave();
    }
}

// ======================================== // 

// 웨이브 종료 남은 파티클/아이템 정리 후 다음 웨이브 또는 레벨 종료
void ASpartaGameState::EndWave()
{
    // 남아있는 파티클 제거
    ClearActiveParticles();

    // 웨이브 타이머 정리
    GetWorldTimerManager().ClearTimer(LevelTimerHandle);

    // 월드에 남아있는 모든 BaseItem 검색
    TArray&amp;lt;AActor*&amp;gt; Actors;
    UGameplayStatics::GetAllActorsOfClass(GetWorld(), ABaseItem::StaticClass(), Actors);

    // 남은 아이템 제거
    for (AActor* Item : Actors)
    {
        if (ABaseItem* BaseItem = Cast&amp;lt;ABaseItem&amp;gt;(Item))
        {
            BaseItem-&amp;gt;Destroy();
        }
    }

    // 다음 웨이브로 이동
    CurrentWaveIndex++;

    // 남은 웨이브가 있으면 다음 웨이브 시작
    if (CurrentWaveIndex &amp;lt;= MaxWaves)
    {
        StartWave();
    }
    // 모든 웨이브가 끝나면 레벨 종료
    else
    {
        EndLevel();
    }

}

// ======================================== // 

// 레벨 종료 남은 오브젝트 정리 후 다음 레벨 이동 또는 게임오버
void ASpartaGameState::EndLevel()
{
    // 남아있는 파티클 제거
    ClearActiveParticles();

    // 웨이브 타이머 정리
    GetWorldTimerManager().ClearTimer(LevelTimerHandle);

    // 월드에 남아있는 모든 BaseItem 검색
    TArray&amp;lt;AActor*&amp;gt; Actors;
    UGameplayStatics::GetAllActorsOfClass(GetWorld(), ABaseItem::StaticClass(), Actors);

    // 남은 아이템 제거
    for (AActor* Item : Actors)
    {
        if (ABaseItem* BaseItem = Cast&amp;lt;ABaseItem&amp;gt;(Item))
        {
            // 아이템 내부 타이머 정리
            BaseItem-&amp;gt;ClearTimer();

            // 아이템 제거
            BaseItem-&amp;gt;Destroy();
        }
    }

    // GameInstance 가져오기
    if (UGameInstance* GameInstance = GetGameInstance())
    {
        // 프로젝트 전용 GameInstance로 캐스팅
        USpartaGameInstance* SpartaGameInstance = Cast&amp;lt;USpartaGameInstance&amp;gt;(GameInstance);
        if (SpartaGameInstance)
        {
            // 현재 레벨 점수 반영
            AddScore(Score);

            // 다음 레벨 인덱스로 증가
            CurrentLevelIndex++;
            SpartaGameInstance-&amp;gt;CurrentLevelIndex = CurrentLevelIndex;

            // 최대 레벨을 넘으면 게임오버
            if (CurrentLevelIndex &amp;gt;= MaxLevels)
            {
                OnGameOver();
                return;
            }

            // 다음 레벨 맵이 존재하면 이동
            if (LevelMapNames.IsValidIndex(CurrentLevelIndex))
            {
                UGameplayStatics::OpenLevel(GetWorld(), LevelMapNames[CurrentLevelIndex]);
            }
            // 맵 이름이 없으면 게임오버
            else
            {
                OnGameOver();
            }
        }
    }
}

// ======================================== // 

// 생성된 파티클을 GameState에 등록
void ASpartaGameState::RegisterParticle(UParticleSystemComponent* Particle)
{
    // 유효한 파티클만 배열에 저장
    if (Particle)
    {
        ActiveParticles.Add(Particle);
    }
}

// ======================================== // 

// 웨이브, 레벨 전환 시 남아있는 파티클 제거
void ASpartaGameState::ClearActiveParticles()
{
    // 등록된 파티클 순회
    for (TWeakObjectPtr&amp;lt;UParticleSystemComponent&amp;gt;&amp;amp; WeakParticle : ActiveParticles)
    {
        // 아직 살아있는 파티클만 제거
        if (WeakParticle.IsValid())
        {
            WeakParticle-&amp;gt;DeactivateSystem();
            WeakParticle-&amp;gt;DestroyComponent();
        }
    }

    // 배열 비우기
    ActiveParticles.Empty();
}

// ======================================== // 

// 레벨에 따라 아이템 난이도 조정
void ASpartaGameState::ApplyItemDifficulty(AActor* SpawnedActor)
{
    // 스폰 실패 시 종료
    if (!SpawnedActor)
    {
        return;
    }

    // 지뢰 아이템이면 폭발 시간/데미지 조정
    if (AMineItem* MineItem = Cast&amp;lt;AMineItem&amp;gt;(SpawnedActor))
    {
        MineItem-&amp;gt;ApplyLevelDifficulty(CurrentLevelIndex);
    }

    // 회복 아이템이면 회복량 조정
    if (AHealingItem* HealingItem = Cast&amp;lt;AHealingItem&amp;gt;(SpawnedActor))
    {
        HealingItem-&amp;gt;ApplyLevelDifficulty(CurrentLevelIndex);
    }
}

// ======================================== // 

// 게임 종료 후 메뉴 표시
void ASpartaGameState::OnGameOver()
{
    // 첫 번째 플레이어 컨트롤러 가져오기
    if (APlayerController* PlayerController = GetWorld()-&amp;gt;GetFirstPlayerController())
    {
        // 프로젝트 전용 PlayerController로 캐스팅
        if (ASpartaPlayerController* SpartaPlayerController = Cast&amp;lt;ASpartaPlayerController&amp;gt;(PlayerController))
        {
            // 게임 일시정지
            SpartaPlayerController-&amp;gt;SetPause(true);

            // 게임오버 메뉴 표시
            SpartaPlayerController-&amp;gt;ShowMainMenu(true);
        }
    }

}

// ======================================== // 

// HUD에 시간, 점수, 레벨, 웨이브, 체력 정보 전달
void ASpartaGameState::UpdateHUD()
{   
    // 첫 번째 플레이어 컨트롤러 가져오기
    APlayerController* PlayerController = GetWorld()-&amp;gt;GetFirstPlayerController();

    // 프로젝트 전용 PlayerController로 캐스팅
    ASpartaPlayerController* SpartaPlayerController = Cast&amp;lt;ASpartaPlayerController&amp;gt;(PlayerController);

    // 캐스팅 실패 시 종료
    if (!SpartaPlayerController)
    {
        return;
    }

    // HUD 위젯 가져오기
    USpartaHUDWidget* HUDWidget = SpartaPlayerController-&amp;gt;GetHUDWidget();

    // HUD가 없으면 종료
    if (!HUDWidget)
    {
        return;
    }

    // 총점 기본값
    int32 TotalScore = 0;

    // GameInstance에서 총점 가져오기
    if (USpartaGameInstance* SpartaGameInstance = Cast&amp;lt;USpartaGameInstance&amp;gt;(GetGameInstance()))
    {
        TotalScore = SpartaGameInstance-&amp;gt;TotalScore;
    }

    // 남은 시간 계산
    float RemainingTime = GetWorldTimerManager().GetTimerRemaining(LevelTimerHandle);
    RemainingTime = FMath::Max(0.0f, RemainingTime);

    // 체력 기본값
    float Health = 0.0f;
    float MaxHealth = 100.0f;

    // 플레이어 캐릭터 가져오기
    if (ASpartaCharacter* PlayerCharacter = Cast&amp;lt;ASpartaCharacter&amp;gt;(UGameplayStatics::GetPlayerCharacter(GetWorld(), 0)))
    {
        Health = PlayerCharacter-&amp;gt;GetHealth();
        MaxHealth = PlayerCharacter-&amp;gt;GetMaxHealth();
    }

    // HUD 위젯에 표시할 값 전달
    HUDWidget-&amp;gt;UpdateHUD(
        RemainingTime,
        TotalScore,
        CurrentLevelIndex,
        CurrentWaveIndex,
        Health,
        MaxHealth
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. SpawnVolume - 데이터테이블 기반 아이템 랜덤 스폰&lt;/h4&gt;
&lt;pre id=&quot;code_1777472409289&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// SpawnVolume.h

#pragma once

#include &quot;CoreMinimal.h&quot;
// 아이템 데이터 테이블 구조체 인클루드
#include &quot;ItemSpawnRow.h&quot;
#include &quot;GameFramework/Actor.h&quot;
#include &quot;SpawnVolume.generated.h&quot;

class UBoxComponent;    // 박스 충돌 컴포넌트 클래스 전방 선언

UCLASS()
class SPARTA_API ASpawnVolume : public AActor
{
	GENERATED_BODY()
	
public:	
	ASpawnVolume();

    // 스폰 볼륨의 루트 컴포넌트
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = &quot;Spawning&quot;)
    USceneComponent* SceneComp;

    // 아이템이 생성될 범위를 나타내는 박스 컴포넌트
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = &quot;Spawning&quot;)
    UBoxComponent* SpawningBox;

    // 아이템 클래스와 스폰 확률을 저장한 데이터 테이블
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = &quot;Spawning&quot;)
    UDataTable* ItemDataTable;

    // 데이터 테이블의 확률을 기준으로 랜덤 아이템을 스폰
    UFUNCTION(BlueprintCallable, Category = &quot;Spawning&quot;)
    AActor* SpawnRandomItem();

    // 데이터 테이블에서 확률 계산으로 스폰할 아이템 Row 선택
    FItemSpawnRow* GetRandomItem() const;

    // 전달받은 아이템 클래스를 실제 월드에 생성
    AActor* SpawnItem(TSubclassOf&amp;lt;AActor&amp;gt; ItemClass);

    // SpawningBox 내부의 랜덤 위치 반환
    FVector GetRandomPointInVolume() const;
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1777472418103&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// SpawnVolume.cpp

#include &quot;SpawnVolume.h&quot;
#include &quot;Components/BoxComponent.h&quot;

// 스폰 볼륨 컴포넌트 초기화
ASpawnVolume::ASpawnVolume()
{
    // 매 프레임 Tick이 필요 없으므로 비활성화
    PrimaryActorTick.bCanEverTick = false;

    // 루트 컴포넌트 생성
    SceneComp = CreateDefaultSubobject&amp;lt;USceneComponent&amp;gt;(TEXT(&quot;SceneComponent&quot;));
    SetRootComponent(SceneComp);

    // 아이템 스폰 범위로 사용할 박스 컴포넌트 생성
    SpawningBox = CreateDefaultSubobject&amp;lt;UBoxComponent&amp;gt;(TEXT(&quot;Spawning Box&quot;));
    SpawningBox-&amp;gt;SetupAttachment(SceneComp);

    // 데이터 테이블 기본값 초기화
    ItemDataTable = nullptr;
}

// ======================================== //

// 데이터 테이블 확률에 따라 랜덤 아이템 스폰
AActor* ASpawnVolume::SpawnRandomItem()
{
    // 확률 계산으로 선택된 데이터 Row 가져오기
    if (FItemSpawnRow* SelectedRow = GetRandomItem())
    {
        // Row에 저장된 아이템 클래스 가져오기
        if (UClass* ActualClass = SelectedRow-&amp;gt;ItemClass.Get())
        {
            // 선택된 아이템을 실제로 월드에 스폰
            return SpawnItem(ActualClass);
        }
    }

    // 실패 시 nullptr 반환
    return nullptr;
}

// ======================================== //

// 데이터 테이블에서 확률 기반으로 아이템 Row 선택
FItemSpawnRow* ASpawnVolume::GetRandomItem() const
{
    // DataTable이 있어야 실행(없으면 바로 종료)
    if (!ItemDataTable) return nullptr;

    // 모든 Row를 저장할 배열
    TArray&amp;lt;FItemSpawnRow*&amp;gt; AllRows;

    // GetAllRows에 필요한 컨텍스트 문자열
    static const FString ContextString(TEXT(&quot;ItemSpawnContext&quot;));

    // 데이터 테이블의 모든 Row 가져오기
    ItemDataTable-&amp;gt;GetAllRows(ContextString, AllRows);

    // 데이터가 없으면 종료
    if (AllRows.IsEmpty()) return nullptr;

    // 전체 확률 합산용 변수
    float TotalChance = 0.0f;

    // 모든 아이템의 스폰 확률 합산
    for (const FItemSpawnRow* Row : AllRows)
    {
        // 아이템 목록이 유효한지 확인
        if (Row)
        {
            // 아이템들의 확률을 더해서 총합 계산
            TotalChance += Row-&amp;gt;Spawnchance;
        }
    }

    // 0 ~ 전체 확률 사이 랜덤값 생성
    const float RandValue = FMath::FRandRange(0.0f, TotalChance);

    // 누적 확률 계산용 변수
    float AccumulateChance = 0.0f;

    // 누적 확률 방식으로 선택된 Row 찾기
    for (FItemSpawnRow* Row : AllRows)
    {
        // 현재 Row의 확률을 누적
        AccumulateChance += Row-&amp;gt;Spawnchance;

        // 랜덤 값이 현재 누적 확률 구간에 포함되면 해당 아이템 선택
        if (RandValue &amp;lt;= AccumulateChance)
        {
            // 선택된 아이템을 반환
            return Row;
        }
    }

    // 선택 실패 시 nullptr 반환
    return nullptr;
}

// ======================================== //

// SpawningBox 내부의 랜덤 위치 반환
FVector ASpawnVolume::GetRandomPointInVolume() const
{
    // 박스의 실제 크기 가져오기
    FVector BoxExtent = SpawningBox-&amp;gt;GetScaledBoxExtent();

    // 박스의 월드 위치 가져오기
    FVector BoxOrigin = SpawningBox-&amp;gt;GetComponentLocation();

    // 박스 범위 안에서 랜덤 좌표 생성
    return BoxOrigin + FVector(
        FMath::FRandRange(-BoxExtent.X, BoxExtent.X),
        FMath::FRandRange(-BoxExtent.Y, BoxExtent.Y),
        FMath::FRandRange(-BoxExtent.Z, BoxExtent.Z)
    );
}

// ======================================== //

// 전달받은 아이템 클래스를 월드에 스폰
AActor* ASpawnVolume::SpawnItem(TSubclassOf&amp;lt;AActor&amp;gt; ItemClass)
{
    // 클래스가 없으면 스폰하지 않음
    if (!ItemClass) return nullptr;

    // 랜덤 위치에 아이템 액터 생성
    AActor* SpawnedActor = GetWorld()-&amp;gt;SpawnActor&amp;lt;AActor&amp;gt;(
        ItemClass,
        GetRandomPointInVolume(),
        FRotator::ZeroRotator
    );

    // 생성된 액터 반환
    return SpawnedActor;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;br /&gt;3. BaseItem - 아이템 공통 부모 클래스&lt;/h4&gt;
&lt;pre id=&quot;code_1777472431240&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// BaseItem.h

#pragma once

#include &quot;CoreMinimal.h&quot;
#include &quot;GameFramework/Actor.h&quot;
#include &quot;ItemInterface.h&quot;
#include &quot;BaseItem.generated.h&quot;

class USphereComponent; // 충돌 감지용 Sphere 컴포넌트 전방 선언
class UParticleSystemComponent; // 파티클 컴포넌트 전방 선언

UCLASS()
class SPARTA_API ABaseItem : public AActor, public IItemInterface
{
    GENERATED_BODY()

public:
    ABaseItem();

    // 파티클 삭제 타이머 정리
    void ClearTimer();

protected:
    // 아이템 종류 구분용 이름
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Item&quot;)
    FName ItemType;

    // 아이템의 루트 씬 컴포넌트
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = &quot;Item|Component&quot;)
    USceneComponent* Scene;

    // 플레이어와의 Overlap 감지용 충돌 컴포넌트
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = &quot;Item|Component&quot;)
    USphereComponent* Collision;

    // 아이템 외형 표시용 스태틱 메시
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = &quot;Item|Component&quot;)
    UStaticMeshComponent* StaticMesh;

    // 아이템 획득 시 재생할 파티클
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Item|Effects&quot;)
    UParticleSystem* PickupParticle;

    // 아이템 획득 시 재생할 사운드
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Item|Effects&quot;)
    USoundBase* PickupSound;

    // 파티클 삭제 타이머 핸들
    FTimerHandle DestroyParticleTimerHandle;

    // 아이템과 다른 액터가 겹치기 시작했을 때 호출
    virtual void OnItemOverlap(
        UPrimitiveComponent* OverlappedComp,
        AActor* OtherActor,
        UPrimitiveComponent* OtherComp,
        int32 OtherBodyIndex,
        bool bFromSweep,
        const FHitResult&amp;amp; SweepResult) override;

    // 아이템과 다른 액터의 겹침이 끝났을 때 호출
    virtual void OnItemEndOverlap(
        UPrimitiveComponent* OverlappedComp,
        AActor* OtherActor,
        UPrimitiveComponent* OtherComp,
        int32 OtherBodyIndex) override;

    // 아이템 효과 실행
    virtual void ActivateItem(AActor* Activator) override;

    // 아이템 타입 반환
    virtual FName GetItemType() const override;

    // 아이템 액터 제거
    void DestroyItem();

    // 생성된 파티클 제거
    void DestroyParticle(UParticleSystemComponent* Particle);
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1777472442045&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// BaseItem.cpp

#include &quot;BaseItem.h&quot;
#include &quot;Components/SphereComponent.h&quot;
#include &quot;Kismet/GameplayStatics.h&quot;
#include &quot;Particles/ParticleSystemComponent.h&quot;
#include &quot;TimerManager.h&quot;
#include &quot;SpartaGameState.h&quot;

// 아이템 기본 컴포넌트와 Overlap 이벤트 설정
ABaseItem::ABaseItem()
{
    PrimaryActorTick.bCanEverTick = false;

    // 루트 컴포넌트 생성 및 설정
    Scene = CreateDefaultSubobject&amp;lt;USceneComponent&amp;gt;(TEXT(&quot;Scene&quot;));
    SetRootComponent(Scene);

    // 충돌 컴포넌트 생성 및 설정
    Collision = CreateDefaultSubobject&amp;lt;USphereComponent&amp;gt;(TEXT(&quot;Collision&quot;));

    // 충돌은 막지 않고 Overlap만 감지
    Collision-&amp;gt;SetCollisionProfileName(TEXT(&quot;OverlapAllDynamic&quot;));

    // 루트 컴포넌트로 설정
    Collision-&amp;gt;SetupAttachment(Scene);

    // 스태틱 메시 컴포넌트 생성 및 설정
    StaticMesh = CreateDefaultSubobject&amp;lt;UStaticMeshComponent&amp;gt;(TEXT(&quot;StaticMesh&quot;));
    StaticMesh-&amp;gt;SetupAttachment(Collision);
    StaticMesh-&amp;gt;SetCollisionEnabled(ECollisionEnabled::NoCollision);
    // 메시가 불필요하게 충돌을 막지 않도록 하기 위해, 별도로 NoCollision 등으로 설정할 수 있음.

    // Overlap 이벤트 바인딩
    Collision-&amp;gt;OnComponentBeginOverlap.AddDynamic(this, &amp;amp;ABaseItem::OnItemOverlap);
    Collision-&amp;gt;OnComponentEndOverlap.AddDynamic(this, &amp;amp;ABaseItem::OnItemEndOverlap);

}

// ======================================== //

// 아이템과 다른 액터가 겹쳤을 때 호출
void ABaseItem::OnItemOverlap(
    UPrimitiveComponent* OverlappedComp,
    AActor* OtherActor,
    UPrimitiveComponent* OtherComp,
    int32 OtherBodyIndex,
    bool bFromSweep,
    const FHitResult&amp;amp; SweepResult)
{
    // OtherActor가 플레이어인지 확인 (&quot;Player&quot; 태그 활용)
    if (OtherActor &amp;amp;&amp;amp; OtherActor-&amp;gt;ActorHasTag(&quot;Player&quot;))
    {
        // 화면에 디버그 메시지 출력
        GEngine-&amp;gt;AddOnScreenDebugMessage(-1, 2.0f, FColor::Green, FString::Printf(TEXT(&quot;Overlap!!!&quot;)));
        
        // 아이템 사용 (획득) 로직 호출
        ActivateItem(OtherActor);
    }
}

// ======================================== //

// Overlap 종료 시 호출, 현재는 별도 기능 없음
void ABaseItem::OnItemEndOverlap(
    UPrimitiveComponent* OverlappedComp,
    AActor* OtherActor,
    UPrimitiveComponent* OtherComp,
    int32 OtherBodyIndex)
{
}

// ======================================== //

// 아이템 능력 실행
void ABaseItem::ActivateItem(AActor* Activator)
{
    // 획득 파티클이 있으면 생성
    if (PickupParticle)
    {
        UParticleSystemComponent* Particle = UGameplayStatics::SpawnEmitterAtLocation(
            GetWorld(),
            PickupParticle,
            GetActorLocation(),
            GetActorRotation(),
            false
        );

        // 파티클 생성 성공 시
        if (Particle)
        {
            // GameState에 파티클 등록
            if (ASpartaGameState* SpartaGameState = GetWorld()-&amp;gt;GetGameState&amp;lt;ASpartaGameState&amp;gt;())
            {
                SpartaGameState-&amp;gt;RegisterParticle(Particle);
            }

            // 파티클 안전 참조용 Weak Pointer 생성
            TWeakObjectPtr&amp;lt;UParticleSystemComponent&amp;gt; WeakParticle = Particle;

            // 지역 타이머 핸들 생성
            FTimerHandle LocalParticleTimerHandle;

            // 2초 뒤 파티클 제거
            GetWorld()-&amp;gt;GetTimerManager().SetTimer(
                LocalParticleTimerHandle,
                FTimerDelegate::CreateLambda([WeakParticle]()
                    {
                        // 파티클이 아직 유효하면 제거
                        if (WeakParticle.IsValid())
                        {
                            WeakParticle-&amp;gt;DeactivateSystem();
                            WeakParticle-&amp;gt;DestroyComponent();
                        }
                    }),
                2.0f,
                false
            );
        }
    }

    // 획득 사운드가 있으면 재생
    if (PickupSound)
    {
        UGameplayStatics::PlaySoundAtLocation(
            GetWorld(),
            PickupSound,
            GetActorLocation()
        );
    }

    // 아이템 액터 제거
    DestroyItem();
}

// ======================================== //

// 아이템 타입 반환
FName ABaseItem::GetItemType() const
{
    return ItemType;
}

// ======================================== //

// 아이템 액터 제거
void ABaseItem::DestroyItem()
{
    Destroy();
}

// ======================================== //

// 파티클 제거 함수
void ABaseItem::DestroyParticle(UParticleSystemComponent* Particle)
{
    // 기존 파티클 타이머 정리
    ClearTimer();

    // 파티클이 유효하면 제거
    if (IsValid(Particle))
    {
        Particle-&amp;gt;DestroyComponent();
    }
}

// ======================================== //

// 파티클 삭제 타이머 정리
void ABaseItem::ClearTimer()
{   
    // 월드가 유효하면 타이머 제거
    if (GetWorld())
    {
        GetWorld()-&amp;gt;GetTimerManager().ClearTimer(DestroyParticleTimerHandle);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;br /&gt;4. MineItem - 지뢰 데미지 조절&lt;/h4&gt;
&lt;pre id=&quot;code_1777472454431&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// MineItem.h

#pragma once

#include &quot;CoreMinimal.h&quot;
#include &quot;BaseItem.h&quot;
#include &quot;MineItem.generated.h&quot;

UCLASS()
class SPARTA_API AMineItem : public ABaseItem
{
    GENERATED_BODY()

public:
    AMineItem();

    // 레벨에 따라 폭발 시간과 데미지 조정
    void ApplyLevelDifficulty(int32 LevelIndex);

protected:
    // 폭발 범위를 감지하는 충돌 컴포넌트
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = &quot;Item|Component&quot;)
    USphereComponent* ExplosionCollision;

    // 폭발 시 재생할 파티클
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Item|Effects&quot;)
    UParticleSystem* ExplosionParticle;

    // 폭발 시 재생할 사운드
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Item|Effects&quot;)
    USoundBase* ExplosionSound;

    // 지뢰가 발동된 후 폭발까지 걸리는 시간
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Mine&quot;)
    float ExplosionDelay;

    // 폭발 데미지를 적용할 범위
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Mine&quot;)
    float ExplosionRadius;

    // 플레이어에게 줄 폭발 데미지
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Mine&quot;)
    int ExplosionDamage;

    // 이미 발동된 지뢰인지 확인하는 변수
    bool bHasExploded;

    // 폭발 타이머 핸들
    FTimerHandle ExplosionTimerHandle;

    // 플레이어가 지뢰와 충돌했을 때 발동
    virtual void ActivateItem(AActor* Activator) override;

    // 실제 폭발 처리 함수
    void Explode();
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1777472464173&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// MineItem.cpp


#include &quot;MineItem.h&quot;
#include &quot;Components/SphereComponent.h&quot;
#include &quot;Kismet/GameplayStatics.h&quot;
#include &quot;Particles/ParticleSystemComponent.h&quot;
#include &quot;TimerManager.h&quot;
#include &quot;SpartaGameState.h&quot;

// 지뢰 기본값과 폭발 범위 컴포넌트 설정
AMineItem::AMineItem()
{
    // 기본 폭발 대기 시간
    ExplosionDelay = 3.0f;

    // 기본 폭발 범위
    ExplosionRadius = 300.0f;

    // 기본 폭발 데미지
    ExplosionDamage = 30.0f;

    // 아이템 타입 설정
    ItemType = &quot;Mine&quot;;

    // 지뢰 발동 여부 초기화
    bHasExploded = false;

    // 폭발 범위 감지용 컴포넌트 생성
    ExplosionCollision = CreateDefaultSubobject&amp;lt;USphereComponent&amp;gt;(TEXT(&quot;ExplosionCollision&quot;));
    // 폭발 범위 설정
    ExplosionCollision-&amp;gt;InitSphereRadius(ExplosionRadius);
    // Overlap 감지용 충돌 프로파일 설정
    ExplosionCollision-&amp;gt;SetCollisionProfileName(TEXT(&quot;OverlapAllDynamic&quot;));
    // Scene 컴포넌트에 부착
    ExplosionCollision-&amp;gt;SetupAttachment(Scene);
}

// ======================================== //

// 레벨에 따라 지뢰 난이도 조정
void AMineItem::ApplyLevelDifficulty(int32 LevelIndex)
{
    // 레벨이 오를수록 폭발 시간이 짧아짐, 최소 0.5초 유지
    ExplosionDelay = FMath::Max(0.5f, 1.5f - LevelIndex * 0.5f);

    // 레벨이 오를수록 데미지 증가
    ExplosionDamage = 30 + LevelIndex * 10;
}

// ======================================== //

// 플레이어가 지뢰에 닿았을 때 호출
void AMineItem::ActivateItem(AActor* Activator)
{
    // 이미 발동된 지뢰면 중복 실행 방지
    if (bHasExploded) return;

    // 지뢰 발동 상태로 변경
    bHasExploded = true;

    // 기본 충돌을 꺼서 중복 Overlap 방지
    if (Collision)
    {
        Collision-&amp;gt;SetCollisionEnabled(ECollisionEnabled::NoCollision);
    }

    // 일정 시간 후 폭발 실행
    GetWorld()-&amp;gt;GetTimerManager().SetTimer(
        ExplosionTimerHandle,
        this,
        &amp;amp;AMineItem::Explode,
        ExplosionDelay,
        false
    );

    // 지뢰 발동 사운드 재생
    if (PickupSound)
    {
        UGameplayStatics::PlaySoundAtLocation(
            GetWorld(),
            PickupSound,
            GetActorLocation()
        );
    }
}

// ======================================== //

// 지뢰 폭발 처리
void AMineItem::Explode()
{
    // 생성된 폭발 파티클 저장용
    UParticleSystemComponent* Particle = nullptr;

    // 폭발 파티클 생성
    if (ExplosionParticle)
    {
        Particle = UGameplayStatics::SpawnEmitterAtLocation(
            GetWorld(),
            ExplosionParticle,
            GetActorLocation(),
            GetActorRotation(),
            false
        );
    }

    // 폭발 사운드 재생
    if (ExplosionSound)
    {
        UGameplayStatics::PlaySoundAtLocation(
            GetWorld(),
            ExplosionSound,
            GetActorLocation()
        );
    }

    // 폭발 범위 안에 있는 액터 배열
    TArray&amp;lt;AActor*&amp;gt; OverlappingActors;

    // 폭발 범위에 겹쳐 있는 액터 가져오기
    ExplosionCollision-&amp;gt;GetOverlappingActors(OverlappingActors);

    // 겹쳐 있는 액터 순회
    for (AActor* Actor : OverlappingActors)
    {
        // 플레이어 태그가 있는 액터만 데미지 적용
        if (Actor &amp;amp;&amp;amp; Actor-&amp;gt;ActorHasTag(&quot;Player&quot;))
        {
            UGameplayStatics::ApplyDamage(
                Actor,
                ExplosionDamage,
                nullptr,
                this,
                UDamageType::StaticClass()
            );
        }
    }

    // 파티클이 생성되었을 경우
    if (Particle)
    {
        // GameState에 파티클 등록
        if (ASpartaGameState* SpartaGameState = GetWorld()-&amp;gt;GetGameState&amp;lt;ASpartaGameState&amp;gt;())
        {
            SpartaGameState-&amp;gt;RegisterParticle(Particle);
        }

        // 파티클 안전 참조용 Weak Pointer
        TWeakObjectPtr&amp;lt;UParticleSystemComponent&amp;gt; WeakParticle = Particle;

        // 파티클 삭제용 지역 타이머
        FTimerHandle LocalParticleTimerHandle;

        // 2초 뒤 파티클 제거
        GetWorld()-&amp;gt;GetTimerManager().SetTimer(
            LocalParticleTimerHandle,
            FTimerDelegate::CreateLambda([WeakParticle]()
                {
                    // 파티클이 아직 유효하면 제거
                    if (WeakParticle.IsValid())
                    {
                        WeakParticle-&amp;gt;DeactivateSystem();
                        WeakParticle-&amp;gt;DestroyComponent();
                    }
                }),
            2.0f,
            false
        );
    }

    // 폭발 후 지뢰 제거
    DestroyItem();
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;br /&gt;5. HealingItem - 회복량 조절&lt;/h4&gt;
&lt;pre id=&quot;code_1777472475418&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// HealingItem.h

#pragma once

#include &quot;CoreMinimal.h&quot;
#include &quot;BaseItem.h&quot;
#include &quot;HealingItem.generated.h&quot;

UCLASS()
class SPARTA_API AHealingItem : public ABaseItem
{
    GENERATED_BODY()

public:
    AHealingItem();

    // 레벨에 따라 회복량 조정
    void ApplyLevelDifficulty(int32 LevelIndex);

protected:
    // 회복량
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Healing&quot;)
    int32 HealAmount;

    // 플레이어가 아이템을 획득했을 때 회복 효과 실행
    virtual void ActivateItem(AActor* Activator) override;
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1777472481657&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// HealingItem.cpp

#include &quot;HealingItem.h&quot;
#include &quot;SpartaCharacter.h&quot;


// 회복 아이템 기본값 설정
AHealingItem::AHealingItem()
{
    HealAmount = 20.0f;
    ItemType = &quot;Healing&quot;;
}

// ======================================== //

// 레벨에 따라 회복량 조정
void AHealingItem::ApplyLevelDifficulty(int32 LevelIndex)
{
    // 레벨이 올라갈수록 회복량 감소, 최소 10 유지
    HealAmount = FMath::Max(10, 30 - LevelIndex * 5);
}

// ======================================== //

// 플레이어가 회복 아이템을 획득했을 때 호출
void AHealingItem::ActivateItem(AActor* Activator)
{
    // 부모 클래스의 공통 효과 실행
    // 파티클, 사운드 재생 및 아이템 제거 처리
    Super::ActivateItem(Activator);

    // 획득한 액터가 플레이어인지 확인
    if (Activator &amp;amp;&amp;amp; Activator-&amp;gt;ActorHasTag(&quot;Player&quot;))
    {
        // 플레이어 캐릭터로 캐스팅
        if (ASpartaCharacter* PlayerCharacter = Cast&amp;lt;ASpartaCharacter&amp;gt;(Activator))
        {
            // 체력 회복 적용
            PlayerCharacter-&amp;gt;AddHealth(HealAmount);
        }

        // 아이템 제거
        DestroyItem();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;br /&gt;6. PlayerController - 메뉴/HUD 위젯 전환&lt;/h4&gt;
&lt;pre id=&quot;code_1777472524235&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// SpartaPlayerController.h

#pragma once

#include &quot;CoreMinimal.h&quot;
#include &quot;GameFramework/PlayerController.h&quot;
#include &quot;SpartaPlayerController.generated.h&quot;

class UInputMappingContext; // IMC 관련 전방 선언
class UInputAction; // IA 관련 전방 선언
class USpartaHUDWidget; // HUDWidget 클래스 전방 선언
class USpartaMainMenuWidget; // 메인메뉴Widget 클래스 전방 선언

UCLASS()
class SPARTA_API ASpartaPlayerController : public APlayerController
{
    GENERATED_BODY()

public:
    ASpartaPlayerController();

    // 에디터 입력 매핑 컨텍스트
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Input&quot;)
    UInputMappingContext* InputMappingContext;

    // 이동 입력 액션
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Input&quot;)
    UInputAction* MoveAction;

    // 점프 입력 액션
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Input&quot;)
    UInputAction* JumpAction;

    // 시점 회전 입력 액션
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Input&quot;)
    UInputAction* LookAction;

    // 달리기 입력 액션
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Input&quot;)
    UInputAction* SprintAction;

    // HUD 위젯 블루프린트 클래스
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;HUD&quot;)
    TSubclassOf&amp;lt;USpartaHUDWidget&amp;gt; HUDWidgetClass;

    // 실제 생성된 HUD 위젯 인스턴스
    UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = &quot;HUD&quot;)
    USpartaHUDWidget* HUDWidgetInstance;

    // HUD 위젯 반환
    UFUNCTION(BlueprintPure, Category = &quot;HUD&quot;)
    USpartaHUDWidget* GetHUDWidget() const;

    // 메인 메뉴 위젯 블루프린트 클래스
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Menu&quot;)
    TSubclassOf&amp;lt;USpartaMainMenuWidget&amp;gt; MainMenuWidgetClass;

    // 실제 생성된 메인 메뉴 위젯 인스턴스
    UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = &quot;Menu&quot;)
    USpartaMainMenuWidget* MainMenuWidgetInstance;

    // 게임 HUD 표시
    UFUNCTION(BlueprintCallable, Category = &quot;Menu&quot;)
    void ShowGameHUD();

    // 메인 메뉴 또는 게임오버 메뉴 표시
    UFUNCTION(BlueprintCallable, Category = &quot;Menu&quot;)
    void ShowMainMenu(bool bIsRestart);

    // 게임 시작 또는 재시작
    UFUNCTION(BlueprintCallable, Category = &quot;Menu&quot;)
    void StartGame();

    // 메인 메뉴 맵으로 이동
    UFUNCTION(BlueprintCallable, Category = &quot;Menu&quot;)
    void ReturnToMainMenu();

    // 게임 종료
    UFUNCTION(BlueprintCallable, Category = &quot;Menu&quot;)
    void QuitGame();

protected:
    // 게임 시작 시 입력 매핑 및 메뉴 표시 처리
    virtual void BeginPlay() override;
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1777472532017&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// SpartaPlayerController.cpp

#include &quot;SpartaPlayerController.h&quot;
#include &quot;EnhancedInputSubsystems.h&quot;
#include &quot;SpartaHUDWidget.h&quot;
#include &quot;SpartaMainMenuWidget.h&quot;
#include &quot;SpartaGameState.h&quot;
#include &quot;Components/TextBlock.h&quot;
#include &quot;SpartaGameInstance.h&quot;
#include &quot;Kismet/GameplayStatics.h&quot;
#include &quot;Kismet/KismetSystemLibrary.h&quot;

// 포인터 변수 기본값 초기화
ASpartaPlayerController::ASpartaPlayerController()
    : InputMappingContext(nullptr),
    MoveAction(nullptr),
    JumpAction(nullptr),
    LookAction(nullptr),
    SprintAction(nullptr),
    HUDWidgetClass(nullptr),
    HUDWidgetInstance(nullptr),
    MainMenuWidgetClass(nullptr),
    MainMenuWidgetInstance(nullptr)
    // 아직 에디터에서 값이 할당되지 않아서
    // 이상한 값이 들어갈 수도 있으니 기본값을 nullptr로 초기화
{
}

// ======================================== //

// 게임 시작 시 입력 매핑과 메뉴 표시 처리
void ASpartaPlayerController::BeginPlay()
{
    Super::BeginPlay();

    // 현재 PlayerController에 연결된 Local Player 객체를 가져옴    
    if (ULocalPlayer* LocalPlayer = GetLocalPlayer())
    {
        // Local Player에서 Enhanced Input Subsystem 가져오ㅁ
        if (UEnhancedInputLocalPlayerSubsystem* Subsystem =
            LocalPlayer-&amp;gt;GetSubsystem&amp;lt;UEnhancedInputLocalPlayerSubsystem&amp;gt;())
        {
            // 입력 매핑 컨텍스트가 있으면 적용
            if (InputMappingContext)
            {
                // Subsystem을 통해 IMC를 활성화(입력 매핑 적용)
                // 0은 가장 높은 우선순위(Priority)
                Subsystem-&amp;gt;AddMappingContext(InputMappingContext, 0);
            }
        }
    }

    // 현재 맵 이름 확인
    FString CurrentMapName = GetWorld()-&amp;gt;GetMapName();

    // 메뉴 레벨이면 메인 메뉴 표시
    if (CurrentMapName.Contains(&quot;MenuLevel&quot;))
    {
        ShowMainMenu(false);
    }
}

// ======================================== //

// 메인 메뉴 또는 게임오버 메뉴 표시
void ASpartaPlayerController::ShowMainMenu(bool bIsRestart)
{
    // 기존 HUD 제거
    if (HUDWidgetInstance)
    {
        HUDWidgetInstance-&amp;gt;RemoveFromParent();
        HUDWidgetInstance = nullptr;
    }

    // 기존 메뉴 제거
    if (MainMenuWidgetInstance)
    {
        MainMenuWidgetInstance-&amp;gt;RemoveFromParent();
        MainMenuWidgetInstance = nullptr;
    }

    // 메뉴 위젯 클래스가 설정되어 있으면 생성
    if (MainMenuWidgetClass)
    {
        MainMenuWidgetInstance = CreateWidget&amp;lt;USpartaMainMenuWidget&amp;gt;(this, MainMenuWidgetClass);
        if (MainMenuWidgetInstance)
        {
            // 메뉴를 화면에 추가
            MainMenuWidgetInstance-&amp;gt;AddToViewport();

            // 마우스 커서 표시 및 UI 입력 모드 설정
            bShowMouseCursor = true;
            SetInputMode(FInputModeUIOnly());

            // 총점수 기본값
            int32 TotalScore = 0;

            // GameInstance에서 총점 가져오기
            if (USpartaGameInstance* SpartaGameInstance = Cast&amp;lt;USpartaGameInstance&amp;gt;(UGameplayStatics::GetGameInstance(this)))
            {
                TotalScore = SpartaGameInstance-&amp;gt;TotalScore;
            }

            // 메뉴 상태 설정
            MainMenuWidgetInstance-&amp;gt;SetupMenu(bIsRestart, TotalScore);
        }
    }
}

// ======================================== //

// 게임 HUD 표시
void ASpartaPlayerController::ShowGameHUD()
{
    // 기존 HUD 제거
    if (HUDWidgetInstance)
    {
        HUDWidgetInstance-&amp;gt;RemoveFromParent();
        HUDWidgetInstance = nullptr;
    }

    // 기존 메뉴 제거
    if (MainMenuWidgetInstance)
    {
        MainMenuWidgetInstance-&amp;gt;RemoveFromParent();
        MainMenuWidgetInstance = nullptr;
    }

    // HUD 위젯 클래스가 설정되어 있으면 생성
    if (HUDWidgetClass)
    {
        HUDWidgetInstance = CreateWidget&amp;lt;USpartaHUDWidget&amp;gt;(this, HUDWidgetClass);
        if (HUDWidgetInstance)
        {
            // HUD를 화면에 추가
            HUDWidgetInstance-&amp;gt;AddToViewport();

            // 마우스 커서 숨김 및 게임 입력 모드 설정
            bShowMouseCursor = false;
            SetInputMode(FInputModeGameOnly());
        }

        // HUD 생성 직후 한 번 갱신
        ASpartaGameState* SpartaGameState = GetWorld() ? GetWorld()-&amp;gt;GetGameState&amp;lt;ASpartaGameState&amp;gt;() : nullptr;
        if (SpartaGameState)
        {
            SpartaGameState-&amp;gt;UpdateHUD();
        }
    }
}

// ======================================== //

// 게임 시작 또는 재시작
void ASpartaPlayerController::StartGame()
{
    // GameInstance 점수와 레벨 인덱스 초기화
    if (USpartaGameInstance* SpartaGameInstance = Cast&amp;lt;USpartaGameInstance&amp;gt;(UGameplayStatics::GetGameInstance(this)))
    {
        SpartaGameInstance-&amp;gt;CurrentLevelIndex = 0;
        SpartaGameInstance-&amp;gt;TotalScore = 0;
    }

    // 첫 번째 레벨로 이동
    UGameplayStatics::OpenLevel(GetWorld(), FName(&quot;Level1&quot;));

    // 일시정지 해제
    SetPause(false);
}

// ======================================== //

// HUD 위젯 인스턴스 반환
USpartaHUDWidget* ASpartaPlayerController::GetHUDWidget() const
{
    return HUDWidgetInstance;
}

// ======================================== //

// 메인 메뉴 맵으로 이동
void ASpartaPlayerController::ReturnToMainMenu()
{
    UGameplayStatics::OpenLevel(GetWorld(), FName(&quot;MenuLevel&quot;));
}

// ======================================== //

// 게임 종료
void ASpartaPlayerController::QuitGame()
{
    UKismetSystemLibrary::QuitGame(this, this, EQuitPreference::Quit, false);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;br /&gt;7. HUDWidget / MainMenuWidget - UI 관리&lt;/h4&gt;
&lt;pre id=&quot;code_1777472542789&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// SpartaHUDWidget.h

#pragma once

#include &quot;CoreMinimal.h&quot;
#include &quot;Blueprint/UserWidget.h&quot;
#include &quot;SpartaHUDWidget.generated.h&quot;

class UTextBlock;   // HUD에 표시할 텍스트 위젯 전방 선언
class UProgressBar; // 체력바 표시용 ProgressBar 전방 선언

UCLASS()
class SPARTA_API USpartaHUDWidget : public UUserWidget
{
	GENERATED_BODY()

public:
    // GameState에서 전달받은 값으로 HUD를 갱신
    void UpdateHUD(
        float RemainingTime,
        int32 TotalScore,
        int32 LevelIndex,
        int32 WaveIndex,
        float Health,
        float MaxHealth
    );

protected:
    // 남은 시간 표시 텍스트
    UPROPERTY(meta = (BindWidget))
    UTextBlock* TimeValue;

    // 점수 표시 텍스트
    UPROPERTY(meta = (BindWidget))
    UTextBlock* ScoreValue;

    // 현재 레벨 표시 텍스트
    UPROPERTY(meta = (BindWidget))
    UTextBlock* Level;

    // 현재 웨이브 표시 텍스트
    UPROPERTY(meta = (BindWidget))
    UTextBlock* Wave;

    // 체력 수치 표시 텍스트
    UPROPERTY(meta = (BindWidget))
    UTextBlock* HealthValue;

    // 체력 비율 표시 바
    UPROPERTY(meta = (BindWidget))
    UProgressBar* HealthBar;
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1777472552212&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// SpartaHUDWidget.cpp

#include &quot;SpartaHUDWidget.h&quot;
#include &quot;Components/TextBlock.h&quot;
#include &quot;Components/ProgressBar.h&quot;

// HUD에 표시할 값들을 갱신하는 함수
void USpartaHUDWidget::UpdateHUD(
    float RemainingTime,
    int32 TotalScore,
    int32 LevelIndex,
    int32 WaveIndex,
    float Health,
    float MaxHealth
)
{
    // 남은 시간 텍스트 갱신
    if (TimeValue)
    {
        TimeValue-&amp;gt;SetText(FText::FromString(FString::Printf(TEXT(&quot;Time: %.1f&quot;), RemainingTime)));
    }

    // 점수 텍스트 갱신
    if (ScoreValue)
    {
        ScoreValue-&amp;gt;SetText(FText::FromString(FString::Printf(TEXT(&quot;Score: %d&quot;), TotalScore)));
    }

    // 레벨 텍스트 갱신
    // LevelIndex는 0부터 시작하므로 화면에는 +1해서 표시
    if (Level)
    {
        Level-&amp;gt;SetText(FText::FromString(FString::Printf(TEXT(&quot;Level: %d&quot;), LevelIndex + 1)));
    }

    // 웨이브 텍스트 갱신
    if (Wave)
    {
        Wave-&amp;gt;SetText(FText::FromString(FString::Printf(TEXT(&quot;Wave: %d&quot;), WaveIndex)));
    }

    // 체력 수치 텍스트 갱신
    if (HealthValue)
    {
        HealthValue-&amp;gt;SetText(FText::FromString(FString::Printf(TEXT(&quot;HP: %.0f / %.0f&quot;), Health, MaxHealth)));
    }

    // 체력바 비율 갱신
    if (HealthBar)
    {
        // 최대 체력이 0보다 클 때만 비율 계산
        const float HealthPercent = MaxHealth &amp;gt; 0.0f ? Health / MaxHealth : 0.0f;

        // ProgressBar는 0.0 ~ 1.0 비율로 표시
        HealthBar-&amp;gt;SetPercent(HealthPercent);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1777472561094&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// SpartaMainMenuWidget.h

#pragma once

#include &quot;CoreMinimal.h&quot;
#include &quot;Blueprint/UserWidget.h&quot;
#include &quot;SpartaMainMenuWidget.generated.h&quot;

class UTextBlock;
class UButton;

UCLASS()
class SPARTA_API USpartaMainMenuWidget : public UUserWidget
{
    GENERATED_BODY()

public:
    void SetupMenu(bool bIsRestart, int32 TotalScore);

protected:
    UPROPERTY(BlueprintReadOnly, meta = (BindWidget), Category = &quot;Menu&quot;)
    UTextBlock* StartButtonText;

    UPROPERTY(BlueprintReadOnly, meta = (BindWidget), Category = &quot;Menu&quot;)
    UTextBlock* GameOverText;

    UPROPERTY(BlueprintReadOnly, meta = (BindWidget), Category = &quot;Menu&quot;)
    UTextBlock* TotalScoreText;

    UPROPERTY(BlueprintReadOnly, meta = (BindWidget), Category = &quot;Menu&quot;)
    UButton* MainMenuButton;
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1777472568957&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// SpartaMainMenuWidget.cpp

#include &quot;SpartaMainMenuWidget.h&quot;
#include &quot;Components/TextBlock.h&quot;
#include &quot;Components/Button.h&quot;

void USpartaMainMenuWidget::SetupMenu(bool bIsRestart, int32 TotalScore)
{
    if (StartButtonText)
    {
        StartButtonText-&amp;gt;SetText(FText::FromString(bIsRestart ? TEXT(&quot;Restart&quot;) : TEXT(&quot;Start&quot;)));
    }

    if (GameOverText)
    {
        GameOverText-&amp;gt;SetVisibility(bIsRestart ? ESlateVisibility::Visible : ESlateVisibility::Hidden);
    }

    if (TotalScoreText)
    {
        TotalScoreText-&amp;gt;SetVisibility(bIsRestart ? ESlateVisibility::Visible : ESlateVisibility::Hidden);

        if (bIsRestart)
        {
            TotalScoreText-&amp;gt;SetText(FText::FromString(
                FString::Printf(TEXT(&quot;Total Score: %d&quot;), TotalScore)
            ));
        }
    }

    if (MainMenuButton)
    {
        MainMenuButton-&amp;gt;SetVisibility(bIsRestart ? ESlateVisibility::Visible : ESlateVisibility::Hidden);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Unreal C++</category>
      <author>hyunjunstar</author>
      <guid isPermaLink="true">https://hyunjunstar.tistory.com/95</guid>
      <comments>https://hyunjunstar.tistory.com/95#entry95comment</comments>
      <pubDate>Wed, 29 Apr 2026 23:22:52 +0900</pubDate>
    </item>
    <item>
      <title>타이머 실행중에 레벨이 바뀌면 에디터가 종료되는 오류</title>
      <link>https://hyunjunstar.tistory.com/93</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;언리얼 C++에서 타이머를 이용해서 일정 시간이 지나면 파티클이 제거되게끔 구현을 하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 구현한 코드&lt;/p&gt;
&lt;pre id=&quot;code_1776778490340&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// BaseItem.h

#pragma once

#include &quot;CoreMinimal.h&quot;
#include &quot;GameFramework/Actor.h&quot;
#include &quot;ItemInterface.h&quot;
#include &quot;BaseItem.generated.h&quot;

class USphereComponent;

UCLASS()
class SPARTA_API ABaseItem : public AActor, public IItemInterface
{
    GENERATED_BODY()

public:
    ABaseItem();

protected:
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Item&quot;)
    FName ItemType;
    // 루트 컴포넌트 (씬)
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = &quot;Item|Component&quot;)
    USceneComponent* Scene;
    // 충돌 컴포넌트 (플레이어 진입 범위 감지용)
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = &quot;Item|Component&quot;)
    USphereComponent* Collision;
    // 아이템 시각 표현용 스태틱 메시
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = &quot;Item|Component&quot;)
    UStaticMeshComponent* StaticMesh;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Item|Effects&quot;)
    UParticleSystem* PickupParticle;
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Item|Effects&quot;)
    USoundBase* PickupSound;

    virtual void OnItemOverlap(
        UPrimitiveComponent* OverlappedComp,
        AActor* OtherActor,
        UPrimitiveComponent* OtherComp,
        int32 OtherBodyIndex,
        bool bFromSweep,
        const FHitResult&amp;amp; SweepResult) override;

    virtual void OnItemEndOverlap(
        UPrimitiveComponent* OverlappedComp,
        AActor* OtherActor,
        UPrimitiveComponent* OtherComp,
        int32 OtherBodyIndex) override;

    virtual void ActivateItem(AActor* Activator) override;
    virtual FName GetItemType() const override;

    void DestroyItem();
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1776778509559&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// BaseItem.cpp

#include &quot;BaseItem.h&quot;
#include &quot;Components/SphereComponent.h&quot;
#include &quot;Kismet/GameplayStatics.h&quot;
#include &quot;Particles/ParticleSystemComponent.h&quot;

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

    // 루트 컴포넌트 생성 및 설정
    Scene = CreateDefaultSubobject&amp;lt;USceneComponent&amp;gt;(TEXT(&quot;Scene&quot;));
    SetRootComponent(Scene);

    // 충돌 컴포넌트 생성 및 설정
    Collision = CreateDefaultSubobject&amp;lt;USphereComponent&amp;gt;(TEXT(&quot;Collision&quot;));
    // 겹침만 감지하는 프로파일 설정
    Collision-&amp;gt;SetCollisionProfileName(TEXT(&quot;OverlapAllDynamic&quot;));
    // 루트 컴포넌트로 설정
    Collision-&amp;gt;SetupAttachment(Scene);

    // 스태틱 메시 컴포넌트 생성 및 설정
    StaticMesh = CreateDefaultSubobject&amp;lt;UStaticMeshComponent&amp;gt;(TEXT(&quot;StaticMesh&quot;));
    StaticMesh-&amp;gt;SetupAttachment(Collision);
    // 메시가 불필요하게 충돌을 막지 않도록 하기 위해, 별도로 NoCollision 등으로 설정할 수 있음.

    // Overlap 이벤트 바인딩
    Collision-&amp;gt;OnComponentBeginOverlap.AddDynamic(this, &amp;amp;ABaseItem::OnItemOverlap);
    Collision-&amp;gt;OnComponentEndOverlap.AddDynamic(this, &amp;amp;ABaseItem::OnItemEndOverlap);
}

void ABaseItem::OnItemOverlap(
    UPrimitiveComponent* OverlappedComp,
    AActor* OtherActor,
    UPrimitiveComponent* OtherComp,
    int32 OtherBodyIndex,
    bool bFromSweep,
    const FHitResult&amp;amp; SweepResult)
{
    // OtherActor가 플레이어인지 확인 (&quot;Player&quot; 태그 활용)
    if (OtherActor &amp;amp;&amp;amp; OtherActor-&amp;gt;ActorHasTag(&quot;Player&quot;))
    {
        GEngine-&amp;gt;AddOnScreenDebugMessage(-1, 2.0f, FColor::Green, FString::Printf(TEXT(&quot;Overlap!!!&quot;)));
        // 아이템 사용 (획득) 로직 호출
        ActivateItem(OtherActor);
    }
}

void ABaseItem::OnItemEndOverlap(
    UPrimitiveComponent* OverlappedComp,
    AActor* OtherActor,
    UPrimitiveComponent* OtherComp,
    int32 OtherBodyIndex)
{
}

void ABaseItem::ActivateItem(AActor* Activator)
{
    UParticleSystemComponent* Particle = nullptr;

    if (PickupParticle)
    {
        Particle = UGameplayStatics::SpawnEmitterAtLocation(
            GetWorld(),
            PickupParticle,
            GetActorLocation(),
            GetActorRotation(),
            true
        );
    }

    if (PickupSound)
    {
        UGameplayStatics::PlaySoundAtLocation(
            GetWorld(),
            PickupSound,
            GetActorLocation()
        );
    }

    if (Particle)
    {
        FTimerHandle DestroyParticleTimerHandle;

        GetWorld()-&amp;gt;GetTimerManager().SetTimer(
            DestroyParticleTimerHandle,
            [Particle]()
            {
                Particle-&amp;gt;DestroyComponent();
            },
            2.0f,
            false
        );
    }
}

FName ABaseItem::GetItemType() const
{
    return ItemType;
}

void ABaseItem::DestroyItem()
{
    Destroy();
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1776778551392&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// MineItem.h

#pragma once

#include &quot;CoreMinimal.h&quot;
#include &quot;BaseItem.h&quot;
#include &quot;MineItem.generated.h&quot;

UCLASS()
class SPARTA_API AMineItem : public ABaseItem
{
    GENERATED_BODY()

public:
    AMineItem();

protected:
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = &quot;Item|Component&quot;)
    USphereComponent* ExplosionCollision;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Item|Effects&quot;)
    UParticleSystem* ExplosionParticle;
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Item|Effects&quot;)
    USoundBase* ExplosionSound;

    // 폭발까지 걸리는 시간 (5초)
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Mine&quot;)
    float ExplosionDelay;
    // 폭발 범위
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Mine&quot;)
    float ExplosionRadius;
    // 폭발 데미지
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Mine&quot;)
    int ExplosionDamage;

    bool bHasExploded;

    // 지뢰 발동 여부
    FTimerHandle ExplosionTimerHandle;

    virtual void ActivateItem(AActor* Activator) override;

    void Explode();
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1776778574462&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// MineItem.cpp


#include &quot;MineItem.h&quot;
#include &quot;Components/SphereComponent.h&quot;
#include &quot;Kismet/GameplayStatics.h&quot;
#include &quot;Particles/ParticleSystemComponent.h&quot;

AMineItem::AMineItem()
{
    ExplosionDelay = 5.0f;
    ExplosionRadius = 300.0f;
    ExplosionDamage = 30.0f;
    ItemType = &quot;Mine&quot;;
    bHasExploded = false;

    ExplosionCollision = CreateDefaultSubobject&amp;lt;USphereComponent&amp;gt;(TEXT(&quot;ExplosionCollision&quot;));
    ExplosionCollision-&amp;gt;InitSphereRadius(ExplosionRadius);
    ExplosionCollision-&amp;gt;SetCollisionProfileName(TEXT(&quot;OverlapAllDynamic&quot;));
    ExplosionCollision-&amp;gt;SetupAttachment(Scene);
}

void AMineItem::ActivateItem(AActor* Activator)
{
    if (bHasExploded) return;

    Super::ActivateItem(Activator);

    GetWorld()-&amp;gt;GetTimerManager().SetTimer(
        ExplosionTimerHandle,
        this,
        &amp;amp;AMineItem::Explode,
        ExplosionDelay,
        false
    );

    bHasExploded = true;
}

void AMineItem::Explode()
{
    UParticleSystemComponent* Particle = nullptr;


    if (ExplosionParticle)
    {
        Particle = UGameplayStatics::SpawnEmitterAtLocation(
            GetWorld(),
            ExplosionParticle,
            GetActorLocation(),
            GetActorRotation(),
            false
        );
    }

    if (ExplosionSound)
    {
        UGameplayStatics::PlaySoundAtLocation(
            GetWorld(),
            ExplosionSound,
            GetActorLocation()
        );
    }

    TArray&amp;lt;AActor*&amp;gt; OverlappingActors;
    ExplosionCollision-&amp;gt;GetOverlappingActors(OverlappingActors);

    for (AActor* Actor : OverlappingActors)
    {
        if (Actor &amp;amp;&amp;amp; Actor-&amp;gt;ActorHasTag(&quot;Player&quot;))
        {
            UGameplayStatics::ApplyDamage(
                Actor,
                ExplosionDamage,
                nullptr,
                this,
                UDamageType::StaticClass()
            );
        }
    }

    // 지뢰 제거
    DestroyItem();

    /*if (Particle)
    {
        FTimerHandle DestroyParticleTimerHandle;

        GetWorld()-&amp;gt;GetTimerManager().SetTimer(
            DestroyParticleTimerHandle,
            [Particle]()
            {
                Particle-&amp;gt;DestroyComponent();
            },
            2.0f,
            false
        );
    }*/
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구현이 다 끝나고 게임 실행을 해서 테스트를 해보니,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레벨이 바뀔때 갑자기 에디터가 종료되고 비주얼스튜디오에서 아래 코드로 이동되면서 디버깅이 실행이 되었다.&lt;/p&gt;
&lt;pre id=&quot;code_1776777623732&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;	// faster than GetValueOnAnyThread()
	T GetValueOnRenderThread() const
	{
		UE::AccessDetection::ReportAccess(UE::AccessDetection::EType::CVar);
#if !defined(__clang__) // @todo Mac: figure out how to make this compile
		// compiled out in shipping for performance (we can change in development later), if this get triggered you need to call GetValueOnGameThread() or GetValueOnAnyThread(), the last one is a bit slower
		cvarCheckCode(ensure(IsInParallelRenderingThread()));	// ensure to not block content creators, #if to optimize in shipping
#endif
		return ShadowedValue[1];
	}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1776777639930&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;cvarCheckCode(ensure(IsInParallelRenderingThread()));	// ensure to not block content creators, #if to optimize in shipping&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;410&quot; data-origin-height=&quot;136&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d3HFso/dJMcacJDfBi/jGY8nGPW9MRruwruKYuya0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d3HFso/dJMcacJDfBi/jGY8nGPW9MRruwruKYuya0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d3HFso/dJMcacJDfBi/jGY8nGPW9MRruwruKYuya0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd3HFso%2FdJMcacJDfBi%2FjGY8nGPW9MRruwruKYuya0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;410&quot; height=&quot;136&quot; data-origin-width=&quot;410&quot; data-origin-height=&quot;136&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1776780404142&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;case FTimerDelegateVariant::IndexOfType&amp;lt;FTimerFunction&amp;gt;():
	{
		if (const FTimerFunction&amp;amp; TimerFunction = VariantDelegate.Get&amp;lt;FTimerFunction&amp;gt;())
		{
			QUICK_SCOPE_CYCLE_COUNTER(STAT_FTimerUnifiedDelegate_Execute);
			TimerFunction();
		}
		break;
	}
default:
	break;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;644&quot; data-origin-height=&quot;297&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/brMAyd/dJMcahxox6l/1MvTCBidmq53H98Ge6ARk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/brMAyd/dJMcahxox6l/1MvTCBidmq53H98Ge6ARk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/brMAyd/dJMcahxox6l/1MvTCBidmq53H98Ge6ARk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbrMAyd%2FdJMcahxox6l%2F1MvTCBidmq53H98Ge6ARk0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;644&quot; height=&quot;297&quot; data-origin-width=&quot;644&quot; data-origin-height=&quot;297&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;계속 테스트 해보면서 원인을 찾아보니 파티클을 제거하는 타이머가 실행중일때 다음 레벨로 바뀌면서 오류가 뜨는거였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레벨1 &amp;gt; 레벨2로 바뀌면서 레벨1에 있던 기존 액터들이 전부 제거되고 레벨2에서 새로운 액터들이 생성 되는 구조인데,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레벨2로 바뀌면서 제거된 후 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;레벨1에서 타이머 작동중인 액터가 있었을때 타이머가 그 액터로 접근을 하면서 오류가 실행 된 것이다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;문제라고 생각한 코드&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1776779073896&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// BaseItem.cpp

void ABaseItem::ActivateItem(AActor* Activator)
{
    UParticleSystemComponent* Particle = nullptr;

    if (Particle)
    {
        FTimerHandle DestroyParticleTimerHandle;

        GetWorld()-&amp;gt;GetTimerManager().SetTimer(
            DestroyParticleTimerHandle,
            [Particle]()
            {
                Particle-&amp;gt;DestroyComponent();
            },
            2.0f,
            false
        );
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1776779101767&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// MineItem.cpp

void AMineItem::Explode()
{
    UParticleSystemComponent* Particle = nullptr;

    if (Particle)
    {
        FTimerHandle DestroyParticleTimerHandle;

        GetWorld()-&amp;gt;GetTimerManager().SetTimer(
            DestroyParticleTimerHandle,
            [Particle]()
            {
                Particle-&amp;gt;DestroyComponent();
            },
            2.0f,
            false
        );
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 두 코드의 타이머가 제거가 안된 상태에서,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레벨이 넘어가며 삭제된 파티클 삭제 타이머에 접근하여 오류가 난 것이라고 생각이 들어서&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래처럼 함수와 변수를 추가해주었고 코드를 수정 하였다&lt;/p&gt;
&lt;pre id=&quot;code_1776859918962&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// BaseItem.h

UCLASS()
class SPARTA_API ABaseItem : public AActor, public IItemInterface
{
    GENERATED_BODY()

public:
    ABaseItem();
	
    // ActivateItem 함수 안에서 지역변수로 사용하던 타이머핸들을
    // 멤버 변수로 분리하여 다른 함수에서도 사용 가능하게 정의
    FTimerHandle DestroyParticleTimerHandle;
    
    // 파티클 삭제를 위한 함수
    // 람다 내부에서 직접 처리하지 않고 함수로 분리하여 사용
    void DestroyParticle(UParticleSystemComponent* Particle);
    
    // DestroyParticleTimerHandle를 삭제하는 함수 
    // 레벨 변경시 타이머로 인한 오류 발생 방지
    void ClearTimer();
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1776860049111&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// BaseItem.cpp

// 파티클이 생성된 경우
if (Particle)
{   // 일정시간(2초) 후 DestroyParticle(Particle); 함수 실행
    GetWorld()-&amp;gt;GetTimerManager().SetTimer(
        DestroyParticleTimerHandle,
        [Particle, this]()
	{   // 기존 Particle-&amp;gt;DestroyComponent(); 대신
    	    // 파티클과 타이머 삭제 함수를 만들어서 사용
    	    DestroyParticle(Particle);
        },
        2.0f,
        false
    );
}

// 파티클과 파티클 삭제 타이머를 제거하는 함수
void ABaseItem::DestroyParticle(UParticleSystemComponent* Particle)
{
    // 파티클 삭제 타이머 함수 호출(타이머 삭제)
    ClearTimer();
    // 생성된 파티클 컴포넌트 삭제
    Particle-&amp;gt;DestroyComponent();
}

// 파티클 삭제 타이머를 제거하는 함수
void ABaseItem::ClearTimer()
{   // DestroyParticleTimerHandle에 등록된 타이머를 제거
    // 레벨 전환시 살아있는 타이머 때문에 생기는 오류 방지
    GetWorld()-&amp;gt;GetTimerManager().ClearTimer(DestroyParticleTimerHandle);
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1776867693181&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// MineItem.cpp

if (Particle)
{
    GetWorld()-&amp;gt;GetTimerManager().SetTimer(
        DestroyParticleTimerHandle,
        [Particle, this]()
        {
            ABaseItem::DestroyParticle(Particle);
        },
        2.0f,
        false
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같이 코드를 수정하고 변수와 함수를 새로 추가해주었고,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레벨이 변경되는 시점에 코드를 추가해주었따.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 수정 전 코드&lt;/p&gt;
&lt;pre id=&quot;code_1776864212461&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// GameState.cpp

void ASpartaGameState::EndLevel()
{
    GetWorldTimerManager().ClearTimer(LevelTimerHandle);

    if (UGameInstance* GameInstance = GetGameInstance())
    {
        USpartaGameInstance* SpartaGameInstance = Cast&amp;lt;USpartaGameInstance&amp;gt;(GameInstance);
        if (SpartaGameInstance)
        {
            AddScore(Score);
            CurrentLevelIndex++;
            SpartaGameInstance-&amp;gt;CurrentLevelIndex = CurrentLevelIndex;

            if (CurrentLevelIndex &amp;gt;= MaxLevels)
            {
                OnGameOver();
                return;
            }

            if (LevelMapNames.IsValidIndex(CurrentLevelIndex))
            {
                UGameplayStatics::OpenLevel(GetWorld(), LevelMapNames[CurrentLevelIndex]);
            }
            else
            {
                OnGameOver();
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 수정한 코드&lt;/p&gt;
&lt;pre id=&quot;code_1776864229505&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// GameState.cpp

void ASpartaGameState::EndLevel()
{
    GetWorldTimerManager().ClearTimer(LevelTimerHandle);
    {   // 현재 월드 내에 존재하는 모든 액터를 찾기 위한 배열 정의
        TArray&amp;lt;AActor*&amp;gt; Actors;
        // BaseItem 클래스를 상속받은 모든 액터를 찾아서 Actors 배열에 저장
        UGameplayStatics::GetAllActorsOfClass(GetWorld(), ABaseItem::StaticClass(), Actors);

        // 찾은 모든 액터를 순회
        for (AActor* Item : Actors)
        {   // AActor를 ABaseItem으로 캐스팅
            if (ABaseItem* BaseItem = Cast&amp;lt;ABaseItem&amp;gt;(Item))
            {   // 액터들에 설정되어 있는 파티클 삭제 타이머를 제거
                BaseItem-&amp;gt;ClearTimer();
            }
        }
    }

    if (UGameInstance* GameInstance = GetGameInstance())
    {
        USpartaGameInstance* SpartaGameInstance = Cast&amp;lt;USpartaGameInstance&amp;gt;(GameInstance);
        if (SpartaGameInstance)
        {
            AddScore(Score);
            CurrentLevelIndex++;
            SpartaGameInstance-&amp;gt;CurrentLevelIndex = CurrentLevelIndex;

            if (CurrentLevelIndex &amp;gt;= MaxLevels)
            {
                OnGameOver();
                return;
            }

            if (LevelMapNames.IsValidIndex(CurrentLevelIndex))
            {
                UGameplayStatics::OpenLevel(GetWorld(), LevelMapNames[CurrentLevelIndex]);
            }
            else
            {
                OnGameOver();
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 문제를 발견하고 해결하면서 타이머는 액터가 삭제되더라도 자동으로 정리되지 않으며,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레벨 전환시 타이머를 직접 관리를 해줘야 한다는 것을 알게 되었다.&lt;/p&gt;</description>
      <category>Unreal C++</category>
      <author>hyunjunstar</author>
      <guid isPermaLink="true">https://hyunjunstar.tistory.com/93</guid>
      <comments>https://hyunjunstar.tistory.com/93#entry93comment</comments>
      <pubDate>Tue, 21 Apr 2026 22:39:38 +0900</pubDate>
    </item>
    <item>
      <title>Unreal C++ 기초 8. 아이템 스폰 및 레벨 데이터 구현</title>
      <link>https://hyunjunstar.tistory.com/92</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 랜덤 위치에 아이템 스폰하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레벨 위에 아이템을 직접 하나하나 배치하는 방식이 아니라,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지정한 영역 내에서 랜덤 위치에 아이템이 생성되도록 구현해보았다.&lt;br /&gt;이를 위해 SpawnVolume이라는 액터를 생성하고 해당 영역 안에서 아이템이 랜덤으로 스폰되도록 설정했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;레벨 셋팅&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 Resources - Maps 폴더에 있는 3개의 레벨을 Content - Maps 폴더로 이동 &amp;gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Project Settings &amp;gt; Maps&amp;amp;Modes &amp;gt; Editor Startup Map, Game Default Map을 BasicLevel 적용&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 레벨은 난이도에 따라 구분되어 있으며, 난이도가 올라갈수록 맵 크기가 작아진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BasicLevel -&amp;gt; IntermediateLevel -&amp;gt; AdvancedLevel 순서&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;액터 생성&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상단 툴바 &amp;gt; Tools 클릭 &amp;gt; New C++ Class 클릭 &amp;gt; Actor 클릭 &amp;gt; 이름 SpawnVolume으로 지정 후 생성&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성 후 Visual Studio 코드 작성(각 코드 설명은 주석처리)&lt;/p&gt;
&lt;pre id=&quot;code_1776517579629&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// SpawnVolume.h

#pragma once

#include &quot;CoreMinimal.h&quot;
#include &quot;GameFramework/Actor.h&quot;
#include &quot;SpawnVolume.generated.h&quot;

// 박스 컴포넌트 전방선언
class UBoxComponent;

UCLASS()
class SPARTAPROJECT_API ASpawnVolume : public AActor
{
    GENERATED_BODY()

public:
    ASpawnVolume();
	
    // 루트 컴포넌트
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = &quot;Spawning&quot;)
    USceneComponent* Scene;
    // 아이템이 생성될 영역 박스 컴포넌트
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = &quot;Spawning&quot;)
    UBoxComponent* SpawningBox;

    // SpawnVolume 범위 내에 랜덤 좌표 반환하는 함수
    UFUNCTION(BlueprintCallable, Category = &quot;Spawning&quot;)
    FVector GetRandomPointInVolume() const;
    
    // 아이템 스폰 함수
    UFUNCTION(BlueprintCallable, Category = &quot;Spawning&quot;)
    void SpawnItem(TSubclassOf&amp;lt;AActor&amp;gt; ItemClass);
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1776517846113&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// SpawnVolume.cpp

#include &quot;SpawnVolume.h&quot;
// UBoxComponent 사용을 위한 인클루드
#include &quot;Components/BoxComponent.h&quot;
#include &quot;Engine/World.h&quot;
#include &quot;GameFramework/Actor.h&quot;

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

    
    Scene = CreateDefaultSubobject&amp;lt;USceneComponent&amp;gt;(TEXT(&quot;Scene&quot;));
    SetRootComponent(Scene);

    SpawningBox = CreateDefaultSubobject&amp;lt;UBoxComponent&amp;gt;(TEXT(&quot;SpawningBox&quot;));
    SpawningBox-&amp;gt;SetupAttachment(Scene);
}

FVector ASpawnVolume::GetRandomPointInVolume() const
{
    // 박스의 절반 크기값을 벡터로 가져옴
    FVector BoxExtent = SpawningBox-&amp;gt;GetScaledBoxExtent();
    // 박스의 기준 좌표이며 중심 위치값을 벡터로 가져옴
    FVector BoxOrigin = SpawningBox-&amp;gt;GetComponentLocation();
	
    // 아이템 최종 스폰 위치
    // 중심 위치 + 랜덤 값(-Extent ~ Extent 사이 값)
    return BoxOrigin + FVector(
        FMath::FRandRange(-BoxExtent.X, BoxExtent.X),
        FMath::FRandRange(-BoxExtent.Y, BoxExtent.Y),
        FMath::FRandRange(-BoxExtent.Z, BoxExtent.Z)
    );
}

void ASpawnVolume::SpawnItem(TSubclassOf&amp;lt;AActor&amp;gt; ItemClass)
{	
    // 아이템 클래스가 있어야 실행
    if (!ItemClass) return;
    // 액터를 생성
    GetWorld()-&amp;gt;SpawnActor&amp;lt;AActor&amp;gt;(
        ItemClass,	// 어떤 아이템을 생성할지 
        GetRandomPointInVolume(),	// 어디에 생성할지
        FRotator::ZeroRotator	// 기본 방향(0, 0, 0)을 기준으로 생성
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;SpawnVolume 영역 지정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성한 C++ 클래스 SpawnVolume 좌클릭 &amp;gt; Create Blueprint class based on SpawnVolume 클릭 &amp;gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BP_SpawnVolume으로 이름 지정후 생성&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 레벨에 배치한 액터들 전부 삭제 후 BP_SpawnVolume를 각 레벨에 배치&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;SpawnVolume 크기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BasicLevel - Location(0, 0, 100) / Scale(90, 90, 3)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IntermediateLevel - Location(0, 0, 100) / Scale(61, 61, 3)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AdvancedLevel - Location(0, 0, 100) / Scale(77, 77, 3)&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 아이템 스폰 확률 데이터 테이블 생성 및 적용&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Item Data 구조체 생성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아이템 스폰 확률을 코드에 직접 작성(하드코딩)하면 값을 수저알 때마다 빌드를 해줘야 해서 비효율적임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 언리얼 엔진의 Data Table을 사용해서 아이템 정보와 확률을 외부 데이터(CSV, JSON 파일 등)로 관리해서&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엔진 안으로 임포트 하여 코드나 블루프린트에서 쉽게 사용 가능하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 외부 데이터를 사용하면 기획자도 쉽게 직접 수정이 가능하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상단 툴바 &amp;gt; Tools 클릭 &amp;gt; New C++ Class 클릭 &amp;gt; None 클릭 &amp;gt; 이름을 ItemSpawnRow로 지정하여 생성&lt;/p&gt;
&lt;pre id=&quot;code_1776520162297&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ItemSpawnRow.h

#pragma once

#include &quot;CoreMinimal.h&quot;
// Data Table에서 Row로 사용하기 위한 FTableRowBase 정의
#include &quot;Engine/DataTable.h&quot;
#include &quot;ItemSpawnRow.generated.h&quot;

// DataTable에서 사용할 Row 구조체이며, 
// FTableRowBase를 상속해야 DataTable로 사용 가능
USTRUCT(BlueprintType)
struct FItemSpawnRow : public FTableRowBase
{
    GENERATED_BODY()

public:
    // 아이템 이름
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    FName ItemName;

    // 하드 레퍼런스 : TSubclassOf
    // - 클래스가 메모리에 바로 로드(빠르게 사용 가능, 소규모 프로젝트)
    // 소프트 레퍼런스 : TSoftClassPtr
    // - 경로만 저장하고 필요할 때 로드(메모리 절약, 대규모 프로젝트)

    // 스폰할 아이템 클래스(AActor를 상속한 클래스만 가능)
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    TSubclassOf&amp;lt;AActor&amp;gt; ItemClass;

    // 스폰될 아이템 확률
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    float Spawnchance;
};&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;아이템 확률 적용(CSV, JSON 임포트 및 언리얼 에디터에서 직접 수정)&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;CSV 파일로 임포트&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;엑셀 파일 실행 후 데이터 입력 &amp;gt; 이름 &lt;/span&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;ItemSpawnTable로 지정 &amp;gt; CSV파일로 저장 &amp;gt; 언리얼 에디터로 이동 &amp;gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 93px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 13.9535%; height: 17px; text-align: center;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;width: 13.7209%; height: 17px; text-align: center;&quot;&gt;ItemName&lt;/td&gt;
&lt;td style=&quot;width: 59.0698%; height: 17px; text-align: center;&quot;&gt;ItemClass&lt;/td&gt;
&lt;td style=&quot;width: 13.2558%; height: 17px; text-align: center;&quot;&gt;Spawnchance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 13.9535%; height: 21px; text-align: center;&quot;&gt;SmallCoinItem&lt;/td&gt;
&lt;td style=&quot;width: 13.7209%; height: 21px; text-align: center;&quot;&gt;SmallCoinItem&lt;/td&gt;
&lt;td style=&quot;width: 59.0698%; height: 21px; text-align: center;&quot;&gt;BP클래스 우클릭 &amp;gt; Copy Reference &amp;gt; 붙여넣기 &amp;gt;&lt;/td&gt;
&lt;td style=&quot;width: 13.2558%; height: 21px; text-align: center;&quot;&gt;30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 13.9535%; height: 21px; text-align: center;&quot;&gt;BigCoinItem&lt;/td&gt;
&lt;td style=&quot;width: 13.7209%; height: 21px; text-align: center;&quot;&gt;BigCoinItem&lt;/td&gt;
&lt;td style=&quot;width: 59.0698%; height: 21px; text-align: center;&quot;&gt;맨 뒤에 _C 붙여주기(아래 예시 참고)&lt;/td&gt;
&lt;td style=&quot;width: 13.2558%; height: 21px; text-align: center;&quot;&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 13.9535%; height: 17px; text-align: center;&quot;&gt;MineItem&lt;/td&gt;
&lt;td style=&quot;width: 13.7209%; height: 17px; text-align: center;&quot;&gt;MineItem&lt;/td&gt;
&lt;td style=&quot;width: 59.0698%; height: 17px; text-align: center;&quot;&gt;/Script/Engine.Blueprint'/Game/Blueprints/BP_MineItem.BP_MineItem_C'&lt;/td&gt;
&lt;td style=&quot;width: 13.2558%; height: 17px; text-align: center;&quot;&gt;40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 13.9535%; height: 17px; text-align: center;&quot;&gt;HealingItem&lt;/td&gt;
&lt;td style=&quot;width: 13.7209%; height: 17px; text-align: center;&quot;&gt;HealingItem&lt;/td&gt;
&lt;td style=&quot;width: 59.0698%; height: 17px; text-align: center;&quot;&gt;위 예시처럼 카피 레퍼런스로 가져와서 뒤에 _C 꼭 붙여주기&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;width: 13.2558%; height: 17px; text-align: center;&quot;&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터 테이블을 생성할 폴더로 이동 &amp;gt; Import to /Game/Blueprints...(경로) 클릭 &amp;gt; ItemSpawnTable.csv 열기 &amp;gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 사진처럼 설정 후 Apply 클릭&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;509&quot; data-origin-height=&quot;598&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b2QRjU/dJMcabKHDtU/GPOFLFIRdY13Fc9qecKMYK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b2QRjU/dJMcabKHDtU/GPOFLFIRdY13Fc9qecKMYK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b2QRjU/dJMcabKHDtU/GPOFLFIRdY13Fc9qecKMYK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb2QRjU%2FdJMcabKHDtU%2FGPOFLFIRdY13Fc9qecKMYK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;509&quot; height=&quot;598&quot; data-origin-width=&quot;509&quot; data-origin-height=&quot;598&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;언리얼 에디터에서 직접 수정&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Blueprints 폴더로 들어가서 빈 공간 우클릭 &amp;gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Miscellaneous&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&amp;gt; Data Table 검색 &amp;gt; 이름 ItemSpawnTable 지정후 생성 &amp;gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;팝업창에 Row Structure를 FItemSpawnRow 선택 후 생성 &amp;gt; 생성된 데이터 테이블 더블클릭 &amp;gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;상단 바에 +Add 클릭해서 직접 수정 가능(Item Class는 선택 해줘야함)&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;SpawnVolume에 확률 적용하기&lt;/h3&gt;
&lt;pre id=&quot;code_1776523811318&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// SpawnVolume.h

#pragma once

#include &quot;CoreMinimal.h&quot;
// 아이템 데이터 테이블 구조체 인클루드
#include &quot;ItemSpawnRow.h&quot;
#include &quot;GameFramework/Actor.h&quot;
#include &quot;SpawnVolume.generated.h&quot;

class UBoxComponent;

UCLASS()
class SPARTA_API ASpawnVolume : public AActor
{
    GENERATED_BODY()
	
public:	
    ASpawnVolume();

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = &quot;Spawning&quot;)
    USceneComponent* SceneComp;
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = &quot;Spawning&quot;)
    UBoxComponent* SpawningBox;

    // 아이템 데이터 테이블 
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = &quot;Spawning&quot;)
    UDataTable* ItemDataTable;

    // 데이터 테이블의 확률 기반으로 랜덤 아이템을 스폰하는 함수
    UFUNCTION(BlueprintCallable, Category = &quot;Spawning&quot;)
    void SpawnRandomItem();

    // 데이터 테이블에서 확률 계산을 통해 하나의 아이템 Row(줄)를 선택하는 함수
    // 누적 확률 방식으로 랜덤 선택
    FItemSpawnRow* GetRandomItem() const;

    void SpawnItem(TSubclassOf&amp;lt;AActor&amp;gt; ItemClass);
    FVector GetRandomPointInVolume() const;
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1776525024338&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// SpawnVolume.cpp

#include &quot;SpawnVolume.h&quot;
#include &quot;Components/BoxComponent.h&quot;

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

    SceneComp = CreateDefaultSubobject&amp;lt;USceneComponent&amp;gt;(TEXT(&quot;SceneComponent&quot;));
    SetRootComponent(SceneComp);

    SpawningBox = CreateDefaultSubobject&amp;lt;UBoxComponent&amp;gt;(TEXT(&quot;Spawning Box&quot;));
    SpawningBox-&amp;gt;SetupAttachment(SceneComp);

    // 초기값 nullptr
    ItemDataTable = nullptr;
}

void ASpawnVolume::SpawnRandomItem()
{
    // 데이터 테이블에서 확률 계산 후 아이템 선택
    // 선택한 아이템 SelectedRow에 저장
    if (FItemSpawnRow* SelectedRow = GetRandomItem())
    {
        // 선택된 아이템을 실제로 꺼냄
        // TusbclassOf -&amp;gt; UClass로 변환
        if (UClass* ActualClass = SelectedRow-&amp;gt;ItemClass.Get())
        {
            // 선택된 아이템을 실제로 월드에 스폰
            SpawnItem(ActualClass);
        }
    }
}

FItemSpawnRow* ASpawnVolume::GetRandomItem() const
{
    // DataTable이 있어야 실행(없으면 바로 종료)
    if (!ItemDataTable) return nullptr;

    // 모든 아이템을 담을 배열 생성
    TArray&amp;lt;FItemSpawnRow*&amp;gt; AllRows;

    static const FString ContextString(TEXT(&quot;ItemSpawnContext&quot;));

    // 데이터 테이블에 있는 모든 아이템 가져오기
    ItemDataTable-&amp;gt;GetAllRows(ContextString, AllRows);

    // 데이터가 없으면 종료
    if (AllRows.IsEmpty()) return nullptr;

    // 전체 확률 합을 저장하는 변수
    float TotalChance = 0.0f;

    // 아이템 반복문 시작
    for (const FItemSpawnRow* Row : AllRows)
    {
        // 아이템 목록이 유효한지 확인
        if (Row)
        {
            // 아이템들의 확률을 더해서 총합 계산
            TotalChance += Row-&amp;gt;Spawnchance;
        }
    }

    // 0 ~ 전체 확률 사이 랜덤값 생성
    const float RandValue = FMath::FRandRange(0.0f, TotalChance);

    // 누적 확률 계산용 변수
    float AccumulateChance = 0.0f;

    // 어떤 아이템을 선택할지 반복문 시작
    for (FItemSpawnRow* Row : AllRows)
    {
        // 누적된 확률 값 생성
        AccumulateChance += Row-&amp;gt;Spawnchance;

        // 랜덤 값이 현재 누적 확률 구간에 포함되면 해당 아이템 선택
        if (RandValue &amp;lt;= AccumulateChance)
        {
            // 선택된 아이템을 반환
            return Row;
        }
    }

    return nullptr;
}

FVector ASpawnVolume::GetRandomPointInVolume() const
{
    FVector BoxExtent = SpawningBox-&amp;gt;GetScaledBoxExtent();
    FVector BoxOrigin = SpawningBox-&amp;gt;GetComponentLocation();

    return BoxOrigin + FVector(
        FMath::FRandRange(-BoxExtent.X, BoxExtent.X),
        FMath::FRandRange(-BoxExtent.Y, BoxExtent.Y),
        FMath::FRandRange(-BoxExtent.Z, BoxExtent.Z)
    );
}


void ASpawnVolume::SpawnItem(TSubclassOf&amp;lt;AActor&amp;gt; ItemClass)
{
    if (!ItemClass) return;

    GetWorld()-&amp;gt;SpawnActor&amp;lt;AActor&amp;gt;(
        ItemClass,
        GetRandomPointInVolume(),
        FRotator::ZeroRotator
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드 작성 후&amp;nbsp;엑셀파일 열어서 데이터 입력후 csv 파일 3개 생성(레벨별 데이터 관리) &amp;gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에디터로 돌아와서 Content - DataTables 폴더 생성 &amp;gt; 해당 폴더에 생성한 csv 파일 세개 임포트 &amp;gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 레벨(총 3개)별로 BP_SpawnVolume 설치 후 클릭 &amp;gt; 우측중단 Details 탭 &amp;gt; item 검색 &amp;gt; Spawning &amp;gt; Item Data Table 적용&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엑셀 파일에서 데이터 확률이나 추가 등 수정한건 DataTable 더블클릭 &amp;gt; 좌측상단 툴바에 Reimort 클릭하면 반영&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 이벤트 그래프 로직 설정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BP_SpawnVolume 더블클릭 &amp;gt; 이벤트 그래프에서&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래처럼 하면 게임 시작시 3초마다 아이템이 1개씩 생성&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;753&quot; data-origin-height=&quot;345&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TVE6c/dJMcajhAtJW/hVRmGsJKanqRRahme9QWlk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TVE6c/dJMcajhAtJW/hVRmGsJKanqRRahme9QWlk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TVE6c/dJMcajhAtJW/hVRmGsJKanqRRahme9QWlk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTVE6c%2FdJMcajhAtJW%2FhVRmGsJKanqRRahme9QWlk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;753&quot; height=&quot;345&quot; data-origin-width=&quot;753&quot; data-origin-height=&quot;345&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래처럼 하면 게임 시작시 10개 생성&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;745&quot; data-origin-height=&quot;193&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/KkAHb/dJMcafGjhYX/aiDoLGF6s2qqALyAYMOtQK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/KkAHb/dJMcafGjhYX/aiDoLGF6s2qqALyAYMOtQK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/KkAHb/dJMcafGjhYX/aiDoLGF6s2qqALyAYMOtQK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKkAHb%2FdJMcafGjhYX%2FaiDoLGF6s2qqALyAYMOtQK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;745&quot; height=&quot;193&quot; data-origin-width=&quot;745&quot; data-origin-height=&quot;193&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Unreal C++</category>
      <author>hyunjunstar</author>
      <guid isPermaLink="true">https://hyunjunstar.tistory.com/92</guid>
      <comments>https://hyunjunstar.tistory.com/92#entry92comment</comments>
      <pubDate>Fri, 17 Apr 2026 23:59:35 +0900</pubDate>
    </item>
  </channel>
</rss>