<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>yeonha.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Mon, 02 Dec 2024 03:29:05 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>yeonha.log</title>
            <url>https://velog.velcdn.com/images/nyong_u_u/profile/e1bbb320-a313-4c6e-8038-69bc3b910231/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. yeonha.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/nyong_u_u" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[2024 지스타 참가 후기 & Trapper 프로젝트를 마무리하며]]></title>
            <link>https://velog.io/@nyong_u_u/2024-%EC%A7%80%EC%8A%A4%ED%83%80-%EC%B0%B8%EA%B0%80-%ED%9B%84%EA%B8%B0-Trapper-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EB%A5%BC-%EB%A7%88%EB%AC%B4%EB%A6%AC%ED%95%98%EB%A9%B0</link>
            <guid>https://velog.io/@nyong_u_u/2024-%EC%A7%80%EC%8A%A4%ED%83%80-%EC%B0%B8%EA%B0%80-%ED%9B%84%EA%B8%B0-Trapper-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EB%A5%BC-%EB%A7%88%EB%AC%B4%EB%A6%AC%ED%95%98%EB%A9%B0</guid>
            <pubDate>Mon, 02 Dec 2024 03:29:05 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><a href="https://youtu.be/j8W2ywaVRY0?si=K2UKvTWJO-_T5szF">Trapper 소개 영상</a> 보러가기</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/252d0c59-52a6-4938-a679-4d01ab4b4fdf/image.png" alt=""></p>
<p>2024년 4월부터 시작해 11월 중순까지 열심히 달렸고, 지스타와 게임인재원 졸업전시까지 끝낸 시점이 왔다. <del>(뭐했다구 또 나이를 먹네요..)</del> 언리얼 엔진의 언자도 모르고 시작한게 엊그제 같은데.. 어쨌든 인재원 덕분에 정말 값진 경험을 얻은 것 같다. 내가 만든 게임을 불특정 다수의 사람들이 플레이하고, 그에 대한 피드백을 받을 수 있는 기회는 정말 흔치 않으니까.</p>
<p>10월 한달간은 폴리싱하고, 코드 갈아엎고 버그 잡는다고 정말 정신이 없었고, 10-11월에 걸쳐서는 포트폴리오를 제작하고 자소서도 몇개 써본다고 글을 포스팅 할 여력이 없었다. 지스타랑 졸업전시 마무리하고, 방학동안 야무지게(?) 쉬고 왔다. 으.. 이제 새삼 진짜 취준 시작이구나 싶다.</p>
<p>어쨌든, 아무것도 모르는 상태로 시작한 블로그라 많이 부족한 글들만 잔뜩 써왔지만, 시작했으니 마무리는 해야겠다는 생각으로 시리즈의 마지막 글을 작성하러 왔다. 아마 프로젝트와 관련된 기록만 남기진 않을 것 같다. 취업을 앞둔 시점에서, 생각을 정리하려고 작성하는 글이기도 하니까.</p>
<h2 id="제일-어려웠던-것">제일 어려웠던 것</h2>
<p>프로젝트를 진행하면서 제일 어려웠던 것은, 역시 아무래도 언리얼 엔진을 배우고 쓰는거였다. 제공해주는 기능이 많은 만큼, 내부 코드를 모르고 쓰면 그만큼 위험성이 높다는 것을 제대로 깨달았다. 그리고 엔진 딴에서는 다 구현되어있는 기능인데, 몰라서 헤맨 것도 많았음.</p>
<p>이전에 인재원에서 배웠던 과정들은 전부 자체엔진 프로젝트였었는데, 아무래도 우리가 0부터 100까지 엔진의 기능을 모두 만들어서 사용하다보니 문제가 생기면 대부분 우리 선에서 해결이 됐었다. 팀원이 만든 기능이라면 물어물어 해결할 수 있었고 내가 만든 기능이라면 문제점을 찾고 고쳐서 쓰면 됐었으니까.</p>
<p>아무튼 나는 언리얼 엔진을 처음 배울 때 그 스케일에 압도당했던 것 같다. 문서와 블로그를 찾아가며 엔진의 기능을 사용하는 방법을 배워야했고, 내부적으로 견고하게 쌓여져있는 코드는 읽기에도, 이해하기에도 정말 막막했다. 하나하나 이해해가며 쓰면 너무 좋겠지만, 모든 기능을 다 알고 쓰려고하면 밑도끝도 없이 파고들어야 하다보니 정신도 없었다.</p>
<p>지금 팀원들이랑 얘기하는거지만, 만약 우리 게임을 더 발전시켜야 하는 상황이 온다면, 갈아엎자는 의견이 99%다. 나도 100% 동의한다 ㅎㅎ. 그럼에도 불구하고, 지금 완성된 게임은 결국 그동안 노력해온 결과물이니까. 새삼 대견하기도 하고, 더 잘할 수 있었을 텐데 하는 후회도 남고 여러 감정이 든다.</p>
<h2 id="절망의-계곡">절망의 계곡</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/550133bc-2db9-4c98-b4f7-6d37c4cabcbe/image.png" alt=""></p>
<p>프로젝트랑 별개로 이제 공부를 한지 2년 다 되어가는데, 절망의 계곡에 여전히 박혀있는 중이다. <del>(언제 나갈 수 있는거지...?)</del> 사실 한살만 어려졌으면 좋겠다. 일년 더 공부하면 지금보다 낫지 않을까, 이런 생각도 많이 한다. 스무살때부터 컴공을 전공한 친구들이 부러웠고, 내가 못하는 것을 너무나도 멋지게 해내는 친구들을 보면 힘들기도 했다. 나도 잘하고 싶은데, 어떻게 해야 더 잘할 수 있을까 매일 고민했으니까.</p>
<p>근데 뭐, 별 수 없다. 그냥 지금처럼 계속 해나가는 수밖에 없는 것 같다. 공부하는동안 몇번이고 넘어졌지만, 나는 결국 오늘까지도 포기하지 않았고, 앞으로도 그럴 것 같다. 그냥 이 과정이 즐거우니까, 즐기기로 했다. 뭐, 이대로 계속 하다보면 언젠가는 잘 하는 사람이 되지 않겠어요?</p>
<h2 id="드디어-지스타">드디어 지스타!</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/30052963-e69c-407a-a4c1-b6563014d4bc/image.png" alt=""></p>
<p>아직도 사진을 보면 믿기지가 않는다. 우와.. 내가 지스타에서 전시를 했다니..! 결국 해내고 말았다..!!!! 디자인을 배웠을 때부터 느낀거지만, 내가 만들고 창작한 무언가가 누군가에게 영향을 끼친다는건 정말 멋진 일이다.</p>
<p>목/금에 상주했던 친구들이 받아온 명함들을 보고 꽤나 놀랐다. 기업의 멋진 분들이 우리가 만든 게임에 관심을 가져주시고, 좋게 평가해주셔서 너무 신기하고 신이났다.</p>
<p>내가 개발했던 많은 것들중 제일 만족했던 부분은 역시나 제일 고생했던 자성이동 기능이었다. 멀쩡하게 잘 돌아가는걸 보면서 얼마나 기뻤는지.. 다들 자성이동만 해도 재밌다고 해줘서 너무너무 좋았다 ㅜㅜ </p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/0ce662f2-b611-46c8-ae0c-774dbb67ed79/image.png" alt=""></p>
<p>애기들이 플레이 해줬던 것이 기억에 남는다. 애기들이 이해하기엔 불친절하고 어려울 수도 있는 게임이었을텐데, 작은 손가락으로 키보드를 하나하나 눌러가며 게임을 조작하고, 재밌게 해주는걸 보니까 괜히 기분이 이상했다. 오래 해주고 간걸 보면, 그래도 꽤 재미있었나보다. </p>
<p>상주하면서 사람들이 플레이하는걸 뒤에서 지켜보면, 되게 조마조마하다. 게임이 언제 터질지 모르고, 사람들이 생각지도 못한 방식으로 플레이하는걸 보면.. 그냥 시중에 나온 게임들이 얼마나 대단한지 새삼 느끼게 된다. 안정적인 플레이 경험을 위해 얼마나 많은 테스트를 거치고, 얼마나 많이 코드를 갈아엎었을까.</p>
<p>사람들에게 더 좋은 경험을 주지 못한 것이 아쉬우면서도, 재밌게 플레이 해주고 칭찬도 해주시는 많은 분들을 만나면서 힘들지만 참 행복했던 것 같다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/40c2d3cf-5d2f-4ec3-9292-1bc99b0cd0bd/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/d8caa562-8955-4ee1-b3f2-31541f99f2f9/image.png" alt=""></p>
<p>나름 디자인 전공을 살려 프로그래밍 팀의 포트폴리오도 만들어봤다. 팀원들에게 원고를 받고, 이미지와 글을 편집해서 한권의 책으로 만들어 함께 전시했다. 모두의 고생과 업적을 작게나마 남기고 싶어서 바쁜 와중에 시간을 쪼개 만든거였는데, 교수님들이 꽤나 관심을 주셨고, 탐내주시는 분들도 많았다. 팀원들도 너무너무 좋아해줬다!! 좀 더 많이 뽑을걸..ㅎㅎ 뿌듯했다. 배워둔 지식은, 결국 어떤 방식으로든 쓸 일이 생긴다는 것을 느꼈다.</p>
<h2 id="기억에-남는-디버깅">기억에 남는 디버깅</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/367a1237-d8a7-48d3-b2db-ce8797be3321/image.png" alt=""></p>
<p>부산 내려가는 SRT에서 언리얼 가비지 컬렉션에 대한 공부를 했다. 왠지 모르겠는데 그냥 이부분을 하나도 몰라서 해놓고 싶었음. 어쨌든 이때를 시작으로 지스타 끝나고도 조금씩 공부를 하고 있었는데, 이것 덕분에 내가 디버깅에 꽤나 기여를 했다. 사실 그래서 남기는 자랑 글이다 ^_^</p>
<p>지스타 끝나고 최종 빌드를 다시 뽑아야 하는 일이 있었는데, 자꾸 의도치 못한 상황에서 계속 크래시가 났었다. 지스타에서도 계속 같은 일이 있었는데, 의심하던 부분이 아닌 다른쪽에서 크래시가 나는 바람에 다들 원인을 찾지 못하고 있었다.</p>
<p>그냥 최근에 공부하고 있기도 했고, 확인해보면 좋을 것 같아서 디버깅하고 있는 팀원에게 변수 선언부쪽을 한번 보여달라고 했는데, 크래시나는 그 변수가 생포인터로 선언되어 있었다. 가비지컬렉션 되고 있을거라는 확신이 들어서 혹시 모르니까 UPROPERTY 붙혀보라고 했고, 빌드를 뽑아서 테스트 해보자고 얘기했다. 순식간에 일어난 일이었는데, 진짜 그 문제가 맞았고....!!!!!! 해결이 되버린 것..!!!! 내가 구현한 부분도 아니었는데 마침 공부하고 있던 부분이었고, 급한 상황이었는데 어쩌다보니 해결되버려서 너무 웃기고 뿌듯했던 경험이었다 ㅋㅋㅋ</p>
<p>프로젝트 디버깅하면서 항상 신기했던 부분이, 보통 게임에서 크래시가 나면 코드도 안봤는데 갑자기 한명이 어... 헐 미안.. 을 외치면서 수정하러 간다 ㅋㅋㅋ 나도 마찬가지구.. 그럴때마다 너무 신기하구 웃기다.</p>
<h2 id="함께여서-더-즐거웠던">함께여서 더 즐거웠던!</h2>
<p>정말 감사하게도, 나는 예전부터 인복이 참 좋다. 이번 프로젝트를 진행하면서 너무 멋진 팀원들과 함께할 수 있어 행복했고, 많은 것을 배우고 공유할 수 있었다. 함께 고민해주는 팀원들이 있어 프로젝트를 진행하는게 두렵지 않았고, 매 순간이 즐거웠던 것 같다. 서로를 믿고 의지할 수 있는 팀원들을 만나 완성까지 힘낼 수 있었다. 이제는 다들 각자만의 길을 찾아가야겠지만, 모두 능력있고 좋은 사람들이라 자기만의 길을 잘 찾아서 갈거라고 확신한다! 또 같이 일할 수 있다면 너무너무 좋을 것 같다 :D 꼭 다시 만나자!</p>
<h2 id="마무리하며">마무리하며</h2>
<p>사실 생각보다 별 내용 없고 두서없는 후기였지만.. 그래도 이렇게 남겨놓음에 의의를 두고 이번 글을 마무리하려고 한다. 쓰고싶은게 생기면 슬쩍 또 추가해 둬야지 ㅎㅎ. 고생많았다 2024년의 나! 취업도 화이팅해보자!!!!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UE5] 네트워크 캐릭터 이동과 관련된 스냅백, 지터링 문제 해결 및 의문점들 feat. 솔루나 시프트]]></title>
            <link>https://velog.io/@nyong_u_u/DAY-47-%EC%BA%90%EB%A6%AD%ED%84%B0-%EC%9D%B4%EB%8F%99-%EB%B2%84%EA%B7%B8-%ED%95%B4%EA%B2%B0-feat.-%EC%86%94%EB%A3%A8%EB%82%98-%EC%8B%9C%ED%94%84%ED%8A%B8-%EC%82%BD%EC%A7%88-%EB%8B%A4%EC%8B%9C-%EC%8B%9C%EC%9E%91</link>
            <guid>https://velog.io/@nyong_u_u/DAY-47-%EC%BA%90%EB%A6%AD%ED%84%B0-%EC%9D%B4%EB%8F%99-%EB%B2%84%EA%B7%B8-%ED%95%B4%EA%B2%B0-feat.-%EC%86%94%EB%A3%A8%EB%82%98-%EC%8B%9C%ED%94%84%ED%8A%B8-%EC%82%BD%EC%A7%88-%EB%8B%A4%EC%8B%9C-%EC%8B%9C%EC%9E%91</guid>
            <pubDate>Sun, 29 Sep 2024 10:41:31 GMT</pubDate>
            <description><![CDATA[<p>예전부터 계속해서 빌드만 뽑으면 클라이언트 캐릭터가 움직일 때 프레임이 떨어지는 것처럼 속도가 느려지고, 순간이동하는 등 여러 문제가 있었다. 에디터 내에서 한 프로세스로 플레이 할 때나, 발표할 때 공유기를 사용해 로컬로 연결할 때는 버벅임이 거의 없는 것 &quot;같은&quot; 느낌이라 그동안은 내 코드가 아니라 내부 인터넷 속도에 탓을 돌리고 흐린눈을 하고 있었는데...</p>
<p>실제로 두 컴퓨터에 랜선을 연결하고 로컬로 테스트 플레이를 진행해보니 똑같이 버벅이는 현상이 있었고, 이게 최적화의 문제인지 내 코드의 문제인지를 판단해야 했다. 이틀정도 언리얼 인사이트를 공부하고, 최적화에 관련된 것들을 찾아보았다. 결과적으로 CPU에서 병목이 생기는 것을 알았고, 어디를 최적화해야할 지에 대한 감은 잡혔는데..</p>
<p>최적화 문제가 아닐 것이라는 확신이 들기 시작했다. 우선, 문제가 &#39;클라이언트&#39; 에서만 발생한다는 것. 애초에 프레임이 떨어진다면 서버나 클라이언트 둘 다 버벅이는게 맞잖아..? 내가 새로 만들어서 달아준 무브먼트 컴포넌트, 솔루나 시프트 동기화 로직이 의심이 가기 시작했고, 실제로 게임모드를 바꿔 언리얼 Third Person 캐릭터를 사용하도록 빌드하여 플레이해보니 모든 문제가 해결되었다ㅎㅎ</p>
<p>이제 어디가 문제인건지 찾고 고쳐야한다. <del>(내가 해결할 수 있을까..)</del></p>
<h2 id="이동-문제-해결하기">이동 문제 해결하기</h2>
<p>자성이동과 이동 모두 같은 문제일거라 생각했는데, 일단 이동과 점프에서 순간이동 하는 문제는 해결된 것 같다. 도대체 이게 왜 켜져있는지 모르겠는데, 캡슐 컴포넌트의 리플리케이트 옵션이 켜져있었다.. 꺼주니까 부드럽게 잘 이동한다.</p>
<h1 id="솔루나-시프트-문제-해결">솔루나 시프트 문제 해결</h1>
<h2 id="이전-게시글-보며-문제-찾아보기">이전 게시글 보며 문제 찾아보기</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/243bf3e1-ef14-43fb-a2fc-6b03e2f54258/image.gif" alt=""></p>
<p>자성이동 때문에 이미 충분히 삽질을 했었던 것 같은데 또 하게 될 줄이야.. PIE에서는 정상적으로 동작하지만 빌드만 뽑으면 클라이언트에서 이상하게 동작한다ㅠ (도대체 왜그러는거야.. 왜!!!!!) </p>
<p>일단, 예전에 작성했던 <a href="https://velog.io/@nyong_u_u/DAY-13-%EC%86%94%EB%A3%A8%EB%82%98-%EC%8B%9C%ED%94%84%ED%8A%B8-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84">솔루나 시프트 삽질 기록</a>을 다시 읽고 어떻게 해결해볼지 고민해보았다.</p>
<ul>
<li><p>클라이언트측에서 로컬로 움직이고, RPC를 이용해 서버쪽의 프록시도 움직여 주었을 때 : 클라이언트쪽에서 지터링이 발생한다 =&gt; 서버의 수정 사항이 반영되어서 생긴 문제로 추측</p>
</li>
<li><p>무브먼트 컴포넌트 오버라이드를 통한 예측 기능 추가 : 지터링은 사라졌지만, 목적지에 도착하기 전에 착지해버림 =&gt; 서버가 이동하고 있는 도중, 클라이언트쪽에서 자성이동을 종료하면서 플래그가 동기화되면서 생기는 문제로 추측</p>
</li>
<li><p>AddMovementInput을 이용한 로직 : 에디터 내에선 잘 동작했으나, 빌드 후 비정상적으로 동작함을 확인. 이때 게시글에선 빌드를 뽑아보지 않았기도 하고, 이것저것 여러 코드가 추가되었기 때문에 어떤게 문제인지 확인 불가능.</p>
</li>
</ul>
<p>이 외에도 서버에서만 델타타임을 계산하도록 하고, 누적시킨 델타타임을 리플리케이트 하여 그 값을 보간해 이동하게끔 하는 로직도 사용해봤는데, 지금까지 해봤던 모든 방법보단 나았지만 지연이 있는 상황이 발생했을 시 가만히 멈추는 문제가 발생했다. 사용성이 너무 좋지 않아서 이것도 폐기.</p>
<h2 id="해결을-위한-2주간의-대장정">해결을 위한 2주간의 대장정</h2>
<p>윗부분을 작성하고, 지금 내용을 작성하기까지 약 2주정도의 시간이 흘렀다. 아무리 생각해도 무브먼트 컴포넌트에 대한 더 심도깊은 이해가 필요하다고 확신했고, 유튜브와 구글, GPT 등 사용할 수 있는 모든 수단을 이용해 무브먼트 컴포넌트에 대한 공부를 했다. 물론 이마저도 시간이 얼마 없었기에 제대로 하진 못했지만, 대충 어떻게 굴러가는구나~ 정도는 이해한 것 같다. 그리고 나서야 확신한건데, 제대로 동작할리가 없는 코드를 사용하고 있었다. 무브먼트 컴포넌트와 클라이언트측 예측에 대한 이해가 없었기에 일어났던 예고된 참사였음.. </p>
<h3 id="이전에-작성했던-코드에-대한-리뷰">이전에 작성했던 코드에 대한 리뷰</h3>
<pre><code class="language-cpp">void ATrapperPlayer::Interact(const FInputActionValue&amp; Value)
{
    float Data = Value.Get&lt;float&gt;();

    if (!Movement-&gt;CanMagneticMoving() || !Data)
        return;

    if (HasAuthority()) MulticastRPCMagneticMoveStart();
    else if (IsLocallyControlled()) ServerRPCMagneticMoveStart();
}</code></pre>
<p>사실상 여기만 보면 될 것 같은데, 자성 이동을 시작하는 쪽의 코드다. 자성이동 로직에서 교수님이 알려주신 <code>AddMovementInput</code> 함수를 사용한 것 까지는 좋았다. 근데.. 무브먼트 컴포넌트를 통해 알아서 처리되고 있기 때문에, 우리는 클라이언트에서 인풋을 입력할때 RPC 함수를 쓰지 않는다.</p>
<p>근데 나는 양쪽의 캐릭터에 모두 인풋을 넣고 있었던 것이다(..이게 말이 되는 코드일까?..) 에디터 내에서는 정상 작동하는 것처럼 보일 수밖에 없었을 것 같다. 패킷 손실이나 지연이 0에 수렴해서 동시에 움직이고, 그럼 서버가 보정을 할 필요가 없었을 테니까.</p>
<p>서버에게 타겟 위치는 전달했지만, 서버가 이동하던 도중 타겟이 바뀌어버린 케이스도 있었을 것이고, 심지어 자성이동 로직에 서버만 Max Speed로 바꾸는 코드도 있다. 점프를 두군데서 호출한 것도 문제였을 것 같고.. <del>(아무튼 총체적 난국이다)</del></p>
<p>무브먼트 컴포넌트가 이동을 어떻게 처리해야하는지 이해하고 인풋 함수를 사용했다면, 문제 없이 잘 구현됐을 것 같은데 하는 아쉬움이 많이 남았다. 추후에 프로젝트가 끝난 뒤, 더 자세한 공부와 함께 여러 사례들을 테스트해보며 이전 문제에 대해 다시 리뷰해볼 생각이다.</p>
<h2 id="해결-후의-의문점들">해결 후의 의문점들</h2>
<p>결국 강의에서 사용한 코드의 구조를 보고 공부하며 최대한 비슷하게 구현해보려고 노력했고, 결론적으로 클라이언트측 예측을 사용해 (<del>아직 찝찝하지만</del>) 해결에 성공하긴 했다. </p>
<p>다만 여기서 로직을 엄청 자세하게 설명하진 않을 예정이고, 위에서 말했듯 프로젝트가 끝난 후에 다른 프로젝트를 하나 파서 다시 구현해볼 것이다. 그때 제대로 정리해서 조금이나마 제대로 된 정보로 포스팅을 작성하려고 한다.</p>
<h3 id="trigger">Trigger</h3>
<pre><code class="language-cpp">void ATrapperPlayer::Interact(const FInputActionValue&amp; Value)
{
    float Data = Value.Get&lt;float&gt;();

    if (Movement-&gt;CanMagneticMoving() &amp;&amp; !Movement-&gt;GetMagneticMovingState())
    {
        if (Movement-&gt;IsFalling())
        {
            // Start Magnetic Move
            if (HasAuthority() &amp;&amp; IsLocallyControlled())
            {
                MulticastWantsToMagneticMove();
            }
            if (!HasAuthority() &amp;&amp; IsLocallyControlled())
            {
                WantsToMagneticMove();
                ServerWantsToMagneticMove();
            }
        }
        else
        {
            // Cast Animation
            if (HasAuthority() &amp;&amp; IsLocallyControlled())
            {
                MulticastPlayCastAnimation();
            }
            if (!HasAuthority() &amp;&amp; IsLocallyControlled())
            {
                PlayCastAnimation();
                ServerPlayCastAnimation();
            }
        }
    }

    return;
}

void ATrapperPlayer::WantsToMagneticMove()
{
    Movement-&gt;bWantsToMagneticMove = true;
    Movement-&gt;bWantsToMagneticCast = true;
}

void ATrapperPlayer::PlayCastAnimation()
{
    Movement-&gt;bWantsToMagneticCast = true;

    UAnimInstance* AnimInstance = GetMesh()-&gt;GetAnimInstance();
    if (!IsValid(AnimInstance) &amp;&amp; !IsValid(MagneticMoveMontage))
    {
        return;
    }

    AnimInstance-&gt;Montage_Play(MagneticMoveMontage, 1.0);
    FOnMontageEnded EndDelegate;
    EndDelegate.BindUObject(this, &amp;ATrapperPlayer::CastEnd);
    AnimInstance-&gt;Montage_SetEndDelegate(EndDelegate, MagneticMoveMontage);
}

void ATrapperPlayer::CastEnd(UAnimMontage* Montage, bool bInterrupted)
{
    Movement-&gt;bWantsToMagneticMove = true;
}</code></pre>
<p>플레이어가 자성이동 트리거 키를 입력했을 때 사용하는 코드이다. 사실 처음에는 로컬 무브먼트 컴포넌트 내의 압축 플래그를 뒤집어주는 식으로만 작성했는데, 각자의 컴퓨터에서 복제되는 캐릭터(서버에서 보는 클라이언트, 클라이언트에서 보는 서버의 복제본)에서 애니메이션이 출력되지 않는 문제가 발생해서 RPC 함수를 통해 플래그를 뒤집어주었다. 이 과정에서 캐스트 애니메이션을 몽타주로 변경해주었다(원래는 애니메이션 상태였음).</p>
<p>내가 공부하고 이해한 바로는, 압축 플래그를 사용하면 몽타주 애니메이션 재생을 제외하고는 RPC 함수 없이도 플래그값과 무브먼트가 서버에도 반영되어 애니메이션이 잘 반영되어야 하는데, 이게 잘 안되어서 의문이었다. 그래서 어떻게든 플래그 상태를 반영하기 위해 RPC 릴라이어블 함수로 변경해주었고, 결론적으로는 잘 된다.. 사실 이게 클라이언트측 예측을 위한 코드로 변경해준 후에 제대로 동작하지 않았던 것들을 해결한 방법의 거의 전부이다. 변수를 리플리케이티드 처리하던가, RPC 함수를 통해 상태를 동기화 하거나.</p>
<p>점프, 낙하 등의 상태일 때는 자성이동 시전 없이 곧바로 bWantsToMagneticMove의 플래그를 뒤집어 자성이동을 실행시키고, 땅에 닿아있는 상태일 때는 캐스트 몽타주 애니메이션을 실행한 후에 자성이동을 실행시킨다.</p>
<h3 id="target">Target</h3>
<pre><code class="language-cpp">void UTrapperPlayerMovementComponent::EnterMagneticMove(EMovementMode PrevMode, ECustomMovementMode PrevCustomMode)
{
    PlayerRef-&gt;StartManeticMovingSetting();

    // Set Target RPC
    if (PlayerRef-&gt;HasAuthority() &amp;&amp; PlayerRef-&gt;IsLocallyControlled())
    {
        ClientSetTargetPos(TargetPosition);
    }
    else if (!PlayerRef-&gt;HasAuthority() &amp;&amp; PlayerRef-&gt;IsLocallyControlled())
    {
        ServerSetTargetPos(TargetPosition);
    }

    // Rotation in Target Direction
    FVector Direction = (TargetPosition - UpdatedComponent-&gt;GetComponentLocation()).GetSafeNormal();
    Direction.Z = 0.0f;
    FQuat Rot = Direction.ToOrientationQuat();
    FHitResult Hit;
    SafeMoveUpdatedComponent(FVector::ZeroVector, Rot, false, Hit);
}

void UTrapperPlayerMovementComponent::ServerSetTargetPos_Implementation(const FVector&amp; TargetPos)
{
    TargetPosition = TargetPos;
    bCanChangeTarget = false;
}

void UTrapperPlayerMovementComponent::ClientSetTargetPos_Implementation(const FVector&amp; TargetPos)
{
    TargetPosition = TargetPos;
}</code></pre>
<p>타겟의 경우 자성 이동에서 사용하는 유일한 외부변수이다. 서버에서 클라이언트의 위치를 보정하고, 클라이언트의 보류된 움직임을 재생성할 때 이 변수가 변경되어 있으면  치명적일 수 있다. 하지만 특정 방향으로 자동 이동하기 위해 꼭 필요한 변수이므로, 서버에 타겟을 세팅해 줄 때 <code>bCanChangeTarget</code> 를 false로 바꿔준 후(리플리케이트 되는 변수). 서버에서 해당 이동을 종료할 때 true로 바꿔주어서 안전하게 처리하려고 노력했다.</p>
<p>그리고 이 경우에 두 컴퓨터로 테스트하는 경우, 클라이언트쪽에 서버가 보낸 리플리케이트 패킷이 손실되는건지 한번씩 클라이언트가 타겟을 설정할 수 없는 경우가 생겼다. </p>
<pre><code class="language-cpp">void UTrapperPlayerMovementComponent::ExitMagneticMove()
{
    PlayerRef-&gt;FinishMagneticMovingSetting();

    // Jump After Arrived &amp; Gravity Set
    GravityScale *= ArrivedJumpGravity;
    bProxyGravityChange = ~bProxyGravityChange;
    Velocity = JumpImpulse * (FVector::UpVector * 0.1f);

    // Set Movement &amp; Flag 
    bWantsToMagneticMove = false;
    bWantsToMagneticCast = false;

    // Change to enable Target Setting
    if (PlayerRef-&gt;HasAuthority() &amp;&amp; !PlayerRef-&gt;IsLocallyControlled())
    {
        bCanChangeTarget = true;

        // 클라이언트에게 자성이동 종료 명시
        GetWorld()-&gt;GetTimerManager().SetTimer(MagneticMoveEndCheckHandle, this,
            &amp;UTrapperPlayerMovementComponent::MagneticMoveEndCheck, 1.f, false, 0.1f);
    }
}

void UTrapperPlayerMovementComponent::ClientMagneticMoveEndCheck_Implementation()
{
    bCanChangeTarget = true;

    if (bWantsToMagneticMove || bWantsToMagneticCast)
    {
        bWantsToMagneticMove = false;
        bWantsToMagneticCast = false;
    }

    PlayerRef-&gt;FinishMagneticMovingSetting();
}</code></pre>
<p>타이머를 이용해 0.1초뒤에 한번 더 체크하도록 클라이언트 RPC를 보내주었고, 지금까지 테스트해본 바로는 아직까지 클라이언트가 타겟을 설정할 수 없는 문제는 없는 것 같다.</p>
<h3 id="proxy">Proxy</h3>
<p>애니메이션도 잘 나오고, 이동도 잘 하는데도 불구하고 있었던 마지막 문제가 있었다. 클라이언트측에서 보는 서버의 프록시가 자성이동을 끝내고 내려올 때 미친듯이 지터링하고 그 후에 하는 점프에서도 지터링하는 현상이 발생했다.</p>
<p>서버의 중력은 변경됐는데 클라이언트쪽의 서버 프록시에서는 변경되지 않아 생긴 문제인 것 같았고, 서버의 중력이 변경될 때마다 리플리케이티드 되는 변수를 하나 만든 뒤 서버의 프록시쪽에서 변수가 복제될 때마다 함수를 호출해 중력 값을 변경해주는 식으로 처리했더니 문제가 해결됐다. 마찬가지로 혹시 몰라 타겟 위치도 복제하도록 변경했다.</p>
<p>서버의 프록시는 서버의 움직임 값만 받아서 보간하는식으로 처리하는 걸로 알고있는데 왜 이런 문제가 발생하는건지 이해를 할 수가 없다.. 프로젝트가 끝난 뒤에 정말 확실하게 알아봐야겠다.</p>
<h2 id="결과">결과</h2>
<p>두 컴퓨터에서 테스트 한 영상이다. 지스타에서도 거의 같은 환경에서 플레이를 진행할 예정이기 때문에, 크게 문제가 생기진 않을 것으로 보인다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/7a104f2f-35df-4a4e-8bb3-359a7e1a667f/image.gif" alt=""></p>
<p>&lt;서버의 로컬 캐릭터가 자성 이동을 사용하는 모습&gt;</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/e95fc56a-094c-469a-a14b-01ab38e86f50/image.gif" alt=""></p>
<p>&lt;클라이언트에 있는 서버의 프록시가 자성 이동을 사용하는 모습&gt;</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/6534bfbc-47db-4423-b7c9-d6795e42ff08/image.gif" alt=""></p>
<p>&lt;클라이언트의 로컬 캐릭터가 자성 이동을 사용하는 모습&gt;</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/85b21974-d245-4954-885a-14a723931528/image.gif" alt=""></p>
<p>&lt;서버에 있는 클라이언트가 자성 이동을 사용하는 모습&gt;</p>
<hr>
<p>드디어 자성 이동의 네트워크 이슈가 사라졌으니, 자성이동 이펙트, 카메라 등 완성도를 위한 작업들을 진행할 수 있게 됐다. 드디어 폴리싱 작업에 합류할 수 있다 ㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠ 이거 해결한다고 컨텐츠쪽을 거의 못하고 있었는데, 듬직한 팀원들이 많이 도와줬다 정말 너무 고맙다....ㅠㅠ 너무 고생한 나에게도 박수를.. 그래도 팀장님이 주신 데드라인에 맞춰 딱 해결되어 너무 다행이구.. 공부도 정말 많이 됐다. 시간적 여유만 있었으면 더 즐겁게 공부하고 해결할 수 있었을텐데 그게 아쉽지만, 뭐 회사를 가도 시간적 여유는 없을테니까..?! 암튼 솔루나 시프트 로직 진짜 진짜로 끝...!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[NDC] <카트라이더> 300km/h 물체의 네트워크 동기화 모델 구현기]]></title>
            <link>https://velog.io/@nyong_u_u/NDC-%EC%B9%B4%ED%8A%B8%EB%9D%BC%EC%9D%B4%EB%8D%94-300kmh-%EB%AC%BC%EC%B2%B4%EC%9D%98-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EB%8F%99%EA%B8%B0%ED%99%94-%EB%AA%A8%EB%8D%B8-%EA%B5%AC%ED%98%84%EA%B8%B0</link>
            <guid>https://velog.io/@nyong_u_u/NDC-%EC%B9%B4%ED%8A%B8%EB%9D%BC%EC%9D%B4%EB%8D%94-300kmh-%EB%AC%BC%EC%B2%B4%EC%9D%98-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EB%8F%99%EA%B8%B0%ED%99%94-%EB%AA%A8%EB%8D%B8-%EA%B5%AC%ED%98%84%EA%B8%B0</guid>
            <pubDate>Fri, 27 Sep 2024 04:01:28 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>NDC의 &quot;〈카트라이더〉 0.001초 차이의 승부&quot; 강의를 듣고 정리한 글입니다.
<a href="https://youtu.be/r4ZaolMQOzE?si=xiOBldRnOnUktjRf">https://youtu.be/r4ZaolMQOzE?si=xiOBldRnOnUktjRf</a></p>
</blockquote>
<h2 id="dedicated-server-동기화-모델">Dedicated Server 동기화 모델</h2>
<h3 id="일반적인-데디케이티드-동기화-모델">일반적인 데디케이티드 동기화 모델?</h3>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/4b21e814-9067-409b-a843-ff0ed1901d83/image.png" alt=""></p>
<p>클라이언트가 서버에게 먼저 이벤트를 요청(Server RPC), 서버가 이벤트를 받고 연산 후 클라이언트에게 되돌려준다(Multicast RPC). </p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/0897a1b8-55df-4ae9-a209-d0e79667308f/image.png" alt=""></p>
<p>미사일 아이템은 다음과 같은 4가지 상태를 갖는다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/9f957a55-369e-45fd-b95e-6f733d237b8e/image.png" alt=""></p>
<p>공격자가 서버에게 미사일을 발사하도록 요청, 서버가 여러가지 조건을 확인하고 미사일을 발사한다.</p>
<p>무브먼트 컴포넌트에서는 사진처럼 딜레이가 발생할 수 있기 때문에, 이런 방식으로 사용하기가 쉽지 않다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/dee29540-2d91-4bf2-8b1a-d9d5ed5e53e4/image.png" alt=""></p>
<p>무브먼트는 인풋을 통해서 캐릭터를 움직일 수 있다. 전진 키를 눌렀을 때 카트가 앞으로 나아가는 식이다. 나아간 결과값을 클라이언트에게 전파해줄 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/a1523e81-bc4f-4422-a8fb-2a56f0722753/image.png" alt=""></p>
<p>내 입력을 서버에 알리고, 결과값만 받으면 서버에서 나온 결과값만 보게 되니까 모두 같은 화면을 보게 된다. 모두 같은 화면을 보는 것은 굉장한 이점이 될 수 있지만, 그 방법에는 한계점이 있다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/a970018a-422f-4ec2-8400-9e2596186ff4/image.png" alt=""></p>
<p>인풋을 서버에서 처리하게 되면 (한국에서 서버에 유저가 들어오는 속도를 보통 20ms로 가정하고 있다) 서버에 핑퐁됐을 시 40ms 정도의 딜레이가 발생할 수 있다. 해외에서 플레이하는 유저라면 10frame 이상의 차이도 발생할 수 있게 된다. 드리프트 등의 세밀한 조작은 1frame 차이로 결과가 크게 다르게 나온다.</p>
<h2 id="client-simulated-model">Client Simulated model</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/80025a6e-fa69-493e-86f1-278415898671/image.png" alt=""></p>
<p>따라서 모든 것을 서버에서 처리할 수는 없고, 일부는 클라이언트에서 처리를 해주어야 한다. Client Simulated model은, 클라이언트가 먼저 계산을 한 후에 계산된 결과를 초당 16번 서버에 보낸다. 서버는 그 결과값을 Validation 하여, 값을 검증한다. </p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/8de2f48b-18e2-4d46-9035-e4ffb5bb845b/image.png" alt=""></p>
<p>P2P의 경우, 클라이언트가 클라이언트에게 시뮬레이트된 결과를 보내면 Validation이 없기 때문에 검증되지 않은 정보를 다른 클라이언트에게 보내게 될 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/90c4762b-ff67-4181-bdb1-2e6af84a6c34/image.png" alt=""></p>
<p>Client Simulated model은 시뮬레이트된 결과를 서버에 보내고, 서버는 검증 후 다른 클라이언트에게 시뮬레이트된 결과를 보내주게 된다.</p>
<h2 id="extrapolation">Extrapolation</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/f870fa6b-e8fb-4246-9bad-52ab5a0f90b7/image.png" alt=""></p>
<p>서버가 처리하는 방식과 클라이언트가 처리하는 방식의 다른점은, 클라이언트가 처리를 하기 때문에 클라이언트가 패킷을 보내기 전에 딜레이가 발생할 수 있다. 대표적인 버그로, 클라이언트 1의 화면에는 1의 카트가 2의 카트보다 빠르다고 되어 있는데, 클라이언트 2의 화면에는 클라이언트 1보다 2의 카트가 빠르다고 보일 수가 있다.
(실제로는 비슷한 위치에 있었음에도 불구하고)</p>
<p>하지만 0.001초로 승부가 갈리는 게임에서, 이렇게 동기화가 이루어지면 안된다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/7cd0b4aa-e293-498f-aef1-1362b459f620/image.png" alt=""></p>
<blockquote>
<p><a href="https://youtu.be/r4ZaolMQOzE?si=a9aWEUZjfiOnATgC&amp;t=479">https://youtu.be/r4ZaolMQOzE?si=a9aWEUZjfiOnATgC&amp;t=479</a></p>
</blockquote>
<p>상하로 클라이언트 1과 2를 나눈 샘플 프로젝트를 준비했다. 동시에 출발함에도, 서로 다른 위치에 존재하는 것처럼 보이는 것을 알 수 있다. 따라서, <code>Extrapolation</code> 이라는 기술을 사용해야 한다.</p>
<p>Extrapolation은 interpolation의 반대말로, 0과 1이 있을 때 2혹은 3과 같은 미래의 값을 유추하는 과정이다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/8751d62e-073e-4d2a-8f79-e86dd9693650/image.png" alt=""></p>
<p>Extrapolation을 하기 위해서는 4가지 정보가 필요하며, 클라이언트는 서버에게 해당 정보를 보내주게 된다.</p>
<ul>
<li>Time Stamp : 클라이언트가 패킷을 보낸 시간</li>
<li>Transform : 카트의 위치</li>
<li>Linear Velocity : 카트의 선형 속도</li>
<li>Angular Velocity : 카트의 각속도</li>
</ul>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/58136e40-9701-426c-a10e-1b45adaad733/image.png" alt=""></p>
<p>동기화를 위해 가장 중요한 것은, 서로의 시간을 맞추는 것이다. 클라이언트 1이 2에게 패킷을 보내는데, 만약 2시에 보냈고 2시 2분에 받았을 때 2분의 차이가 날 것 같지만 둘의 시계가 같다는 보장이 없다. 따라서, 서버의 시간을 받아 서버의 시간으로 비교하는 것이 중요하다.</p>
<p>언리얼 엔진4에서는 GameStateBase 클래스를 통해 서버가 각 클라이언트들에게 ReplicateWorldTimeSeconds를 초당 10frame으로 리플리케이션 해주고, 각자의 시계와 서버의 시계를 비교해 델타를 구하고 서버의 시간을 유추하고 있다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/476e5570-778d-4846-9a45-b4404d5330e7/image.png" alt=""></p>
<p>그렇게 유추된 서버의 시간을 클라이언트 1은 Time Stamp를 찍어서 서버에 보내고, 클라이언트 2는 그 패킷을 받아 클라이언트 1과 2가 시간 차이가 얼마나 나는지 계산한다. </p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/5c32798a-8314-4da0-9e66-da4fbdbaaf3e/image.png" alt=""></p>
<p>Current는 이전 프레임에 계산된 위치이다. 패킷을 받으면 Rep_Position이라는 패킷의 위치가 나올 것인데, 두 클라이언트의 레이턴시가 200ms라고 한다면 200ms 전의 위치일 것이다. 200ms 후인 현재의 위치를 알고 싶은 것이기 때문에, 패킷으로 받은 Linear Velocity와 아까 계산된 ExtrapolationDeltaSeconds를 곱해 타겟 포지션을 구한다. 타겟 포지션을 완전히 믿을 수 없기 때문에, 포지션 interpolation을 통해 New Location을 유추해내고 있다. 조절 파람의 경우, 카트의 속도가 얼마나 빠르고 정확도가 어느정도인지에 따라 조절해줄 수 있다. </p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/a8ab9be4-81aa-4ad1-ba60-e7440006ebe4/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/bf4230e0-6909-4b6b-ad18-9f889c7e62a7/image.png" alt=""></p>
<p>Rotation, Velocity Extrapolation도 마찬가지이다. Position과 동일한 방식으로 유추한다. </p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/30cb34dd-1aa8-4bcf-a98f-1292810bce16/image.png" alt=""></p>
<p>이렇게 유추한 결과를 적용하면, 0.2초 전에 보낸 패킷도 같이 달리는 것처럼 볼 수 있게 된다.</p>
<h3 id="ue4-네트워크-콘솔-명령어">UE4 네트워크 콘솔 명령어</h3>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/f121f660-3d04-4acd-bc96-724f15e27eda/image.png" alt=""></p>
<p>언리얼 엔진4의 콘솔 명령어이다. 이걸 사용해 스트레스 테스트 진행이 가능하다.</p>
<h3 id="hardsnap">HardSnap</h3>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/19c31bcc-d071-4cbc-a970-649a81cc8462/image.png" alt=""></p>
<p>200ms 정도 레이턴시가 있다면, Extrapolation이 미래를 예측하는 것이기 때문에 오차가 발생할 수 있다.</p>
<p>플레이어가 0.2초 후에 왼쪽 키를 누를지, 오른쪽 키를 누를지 알 수가 없기 때문이다. 혹은 UDP 통신이기 때문에 패킷 손실이 발생하여 Extrapolation이 종종 실패하기도 한다. 실제 값과 예측된 값이 오차가 크거나, 오류가 누적되었을 때 보간 없이 HardSnap을 발생시킨다.(텔레포트) 기존의 연산 값들을 버리고, 새로 연산된 값들을 바로 적용시킨다.</p>
<h3 id="파라미터-튜닝">파라미터 튜닝</h3>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/33da8cb3-f8fc-4a22-a365-477ecc784abc/image.png" alt=""></p>
<p>멀리 있는 유저는 더 과거의 모습을 보여주어도 크게 문제가 되지 않기 때문에 예측을 조금 덜 하고, 가까운 유저는 하드 스냅을 더 빈번하게 발생시켜 정확한 위치를 표현해주어야 한다. 카트라이더 내에서 하드 스냅의 조건의 경우, 캐릭터의 속도에 따라 다르게 주어진다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UE5] Character Movement Component Series : Prone Mechanic]]></title>
            <link>https://velog.io/@nyong_u_u/UE5-Character-Movement-Component-Series-Prone-Mechanic</link>
            <guid>https://velog.io/@nyong_u_u/UE5-Character-Movement-Component-Series-Prone-Mechanic</guid>
            <pubDate>Thu, 26 Sep 2024 12:01:41 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Character Movement Component In-Depth 강의 시리즈를 공부하면서 한글로 정리한 포스트입니다. 의역과 오역이 난무하니 주의해주세요! <a href="https://youtu.be/j45CUV9lWTA?si=nsePEHZSnSmg8hMK">https://youtu.be/j45CUV9lWTA?si=nsePEHZSnSmg8hMK</a></p>
</blockquote>
<h2 id="prone-implementation">Prone Implementation</h2>
<pre><code class="language-cpp">UENUM(BlueprintType)
enum ECustomMovementMode
{
    CMOVE_None        UMETA(Hidden),
    CMOVE_Slide        UMETA(DisplayName = &quot;Slide&quot;),
    CMOVE_Prone        UMETA(DisplayName = &quot;Prone&quot;),
    CMOVE_MAX        UMETA(Hidden),
};</code></pre>
<p>새로운 Custom Movement Mode를 추가해주고,</p>
<pre><code class="language-cpp">    /// Prone
    UPROPERTY(EditDefaultsOnly) float ProneEnterHoldDuration = .2f;
    UPROPERTY(EditDefaultsOnly) float ProneSlideEnterImpulse = 300.f;
    UPROPERTY(EditDefaultsOnly) float MaxProneSpeed = 300.f;
    UPROPERTY(EditDefaultsOnly) float BrakingDecelerationProning = 2500.f;

    // Prone
private:
    void EnterProne(EMovementMode PrevMode, ECustomMovementMode PrevCustomMode);
    void ExitProne();
    bool CanProne() const;
    void PhysProne(float deltaTime, int32 Iterations);</code></pre>
<p>Prone과 관련된 변수와 함수를 선언해주자.</p>
<h3 id="physprone">PhysProne()</h3>
<blockquote>
<p>전체 구현부는 저자의 깃허브에서 확인하실 수 있습니다!
<a href="https://github.com/delgoodie/Zippy">https://github.com/delgoodie/Zippy</a></p>
</blockquote>
<p><code>PhysProne</code> 의 경우, <code>PhysWalking</code> 의 코드와 굉장히 유사하게 구현했다. Walking과 Prone은 기본적으로 이동 속도의 변화일 뿐이고, 동작 자체가 매우 유사하다. 이번 튜토리얼에서는 서브 스테핑이 어떻게 작동하는지에 중점을 둘 예정이다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/ae477f6e-baaa-44b6-81d7-815a53deb8c5/image.png" alt=""></p>
<p>우리는 서브 스테핑을 할 것이며, 서브 스테핑이 의미하는 것은 한 프레임에서 여러번 반복하는 것이다. 따라서 프레임이 주어지고, 프레임은 특정 델타 타임으로 구성되어 있다. 한 프레임 안에서 여러번 반복하며, 더 정확하고 움직임에 대해 더 높은 해상도의 시뮬레이션을 할 수 있다. </p>
<pre><code class="language-cpp">float remainingTime = deltaTime;</code></pre>
<p>while문에 들어가기 전, remainingTime에 deltaTime을 넣어준다. </p>
<pre><code class="language-cpp">while ((remainingTime &gt;= MIN_TICK_TIME) &amp;&amp; 
      (Iterations &lt; MaxSimulationIterations) &amp;&amp;
      CharacterOwner &amp;&amp; (CharacterOwner-&gt;Controller || bRunPhysicsWithNoController || (CharacterOwner-&gt;GetLocalRole() == ROLE_SimulatedProxy)))</code></pre>
<p>서브 스테핑에 들어가기 위한 조건문이다. 최소 틱 시간보다 남은 시간이 더 많은지 확인하고, 반복이 최대 시뮬레이션 반복보다 적을때(무한 루프에 들어가는 것을 방지하기 위함), 그리고 밑의 조건들을 만족할 때 서브 스테핑을 실행한다.  </p>
<pre><code class="language-cpp">Iterations++;
bJustTeleported = false;
const float timeTick = GetSimulationTimeStep(remainingTime, Iterations);
remainingTime -= timeTick;</code></pre>
<p>텔레포트를 사용하지 않기 때문에 <code>bJustTeleported</code> 를 false로 설정하고, <code>GetSimulationTimeStep</code> 함수를 이용해 틱 시간을 얻는다.</p>
<pre><code class="language-cpp">// Save current values
UPrimitiveComponent * const OldBase = GetMovementBase();
const FVector PreviousBaseLocation = (OldBase != NULL) ? OldBase-&gt;GetComponentLocation() : FVector::ZeroVector;
const FVector OldLocation = UpdatedComponent-&gt;GetComponentLocation();
const FFindFloorResult OldFloor = CurrentFloor;</code></pre>
<p>모든 현재 값에 대해 저장된 값을 만들어준다. 이렇게 하는 이유는, 방금 계산한 이동을 되돌려야 할 수도 있기 때문이다. </p>
<pre><code class="language-cpp">// Ensure velocity is horizontal.
MaintainHorizontalGroundVelocity();
const FVector OldVelocity = Velocity;
Acceleration.Z = 0.f;

// Apply acceleration
CalcVelocity(timeTick, GroundFriction, false, GetMaxBrakingDeceleration());</code></pre>
<p><code>MaintainHorizontalGroundVelocity</code> 는 속도를 수평으로 유지하는 데 매우 유용한 함수이다. 우리는 지면에 있을 수도 있고, 경사면에 있을 수도, 평평하지 않은 평면에 있을 수도 있지만 속도는 평면에 수평으로 유지된다. 주로 지상 이동 모드에서 도움이 되는 함수이다. <code>Acceleration</code> 은 입력 벡터라는 것을 잊지 말자. 아무튼, Z값을 0으로 변경하여 위 또는 아래로 가속할 수 없게 한다.</p>
<p>마찰은 항상 캡슐에 적용되므로, 캡슐이 움직일 때 마찰이 가해지며 속도가 느려진다. 그렇기 때문에 무한히 가속할 수 없다. 우리는 캐릭터가 걸어다니다가 키를 놓았을 때 곧바로 멈추기를 원한다. 하지만, 이것을 위해 마찰을 높이게 되면 캐릭터의 이동속도가 정말 느려질 것이고, 슬라이드와 같은 이동 모드가 제대로 구현되지 않을 수 있다. 따라서 존재하는 것이 <code>BrakingDeceleration</code> 인데, 이것은 키를 놓았을때와 같이 어떤 움직임도 적용하지 않을 때 적용되는 값이다. 이 값을 이용해 빠르게 멈출 수 있다.</p>
<p>커스텀 무브먼트 모드의 경우 <code>GetMaxBrakingDeceleration()</code> 함수를 사용하면 0을 반환하므로, 함수를 오버라이드 하여 해당 이동 모드의 감속도를 반환해주어야 한다. </p>
<pre><code class="language-cpp">// Compute move parameters
const FVector MoveVelocity = Velocity;
const FVector Delta = timeTick * MoveVelocity; // dx = v * dt
const bool bZeroDelta = Delta.IsNearlyZero();
FStepDownResult StepDownResult;

if ( bZeroDelta )
{
    remainingTime = 0.f;
}
else
{
    MoveAlongFloor(MoveVelocity, timeTick, &amp;StepDownResult);

    if ( IsFalling() )
    {
        // pawn decided to jump up
        const float DesiredDist = Delta.Size();
        if (DesiredDist &gt; KINDA_SMALL_NUMBER)
        {
            const float ActualDist = (UpdatedComponent-&gt;GetComponentLocation() - OldLocation).Size2D();
            remainingTime += timeTick * (1.f - FMath::Min(1.f,ActualDist/DesiredDist));
        }
        StartNewPhysics(remainingTime,Iterations);
        return;
    }
    else if ( IsSwimming() ) //just entered water
    {
        StartSwimming(OldLocation, OldVelocity, timeTick, remainingTime, Iterations);
        return;
    }
}</code></pre>
<p>만약 속도가 0이면 캡슐이 어디로도 움직이지 않으므로 서브스테핑이 필요하지 않게된다. 따라서 시뮬레이션을 서두르기 위해 남은 시간을 즉시 0으로 설정한다.</p>
<p>속도가 0이 아니라면, <code>MoveAlongFloor</code> 함수를 이용해 바닥을 따라 이동한다. 이 함수 또한 매우 유용한 헬퍼 함수인데, 이 함수는 컴포넌트를 안전하게 움직여준다. 이름에서 알 수 있듯 바닥을 따라 움직이며, 이것은 정말 도움이 된다. 다양한 각도의 경사면에서 움직일 수 있고, 각각의 속도와 시간 틱을 가질 수 있으며, StepDownResult 이라는 결과값이 반환되기 때문이다. 현재 있는 표면과 이동중인 표면을 아는 것은 정말 중요하다. 캡슐이 계단과 같은 표면에서 움직인다고 상상해보자. 그 표면은 항상 변하기 때문에, 우리는 그에 따라 처리해주어야 한다. StepDownResult를 통해 변화된 표면을 알 수 있다.</p>
<p>이 줄 자체가 실제로 움직임이 일어나는 곳이며, 나머지는 움직임을 정리하는 코드들이다. 첫번째는 움직이다가 떨어졌을 때의 경우이다. 움직이고 있다가 바닥이 없는 곳으로 떨어지면, 우리는 기본적으로 falling 모드에 들어가게 될 것이다. 여기서 볼 수 있는 멋진 것 중 하나는, 남은 시간과 반복을 사용하여 <code>StartNewPhysics</code> 를 실행한다는 것이다. 새로운 물리 시뮬레이션을 전체 델타 타임이 아니라 우리가 가지고 있는 남은 델타 타임을 사용하여 시뮬레이션 중간에 이동 모드를 전환할 수 있게 된다. <code>StartSwimming</code> 의 경우에도, 같은 경우를 설정한다. 이러한 코드들을 사용해, 걷다가 다른 이동 모드로 전환되는 경우를 처리할 수 있게 된다.</p>
<pre><code class="language-cpp">// Update floor.
// StepUp might have already done it for us.
if (StepDownResult.bComputedFloor)
{
    CurrentFloor = StepDownResult.FloorResult;
}
else
{
    FindFloor(UpdatedComponent-&gt;GetComponentLocation(), CurrentFloor, bZeroDelta, NULL);
}</code></pre>
<p><code>StepDownResult.bComputedFloor</code> 가 true라면, 캐릭터가 아래로 이동할 때 이미 바닥 정보를 계산했다는 뜻이므로 <code>CurrentFloor</code> 에 현재 바닥 정보를 대입해주고,
false 라면 새로운 바닥 정보를 계산한다.</p>
<pre><code class="language-cpp">// check for ledges here
const bool bCheckLedges = !CanWalkOffLedges();
if ( bCheckLedges &amp;&amp; !CurrentFloor.IsWalkableFloor() )
{
    // calculate possible alternate movement
    const FVector GravDir = FVector(0.f,0.f,-1.f);
    const FVector NewDelta = bTriedLedgeMove ? FVector::ZeroVector : GetLedgeMove(OldLocation, Delta, GravDir);
    if ( !NewDelta.IsZero() )
    {
        // first revert this move
        RevertMove(OldLocation, OldBase, PreviousBaseLocation, OldFloor, false);

        // avoid repeated ledge moves if the first one fails
        bTriedLedgeMove = true;

        // Try new movement direction
        Velocity = NewDelta/timeTick; // v = dx/dt
        remainingTime += timeTick;
        continue;
    }
    else
    {
        // see if it is OK to jump
        // @todo collision : only thing that can be problem is that oldbase has world collision on
        bool bMustJump = bZeroDelta || (OldBase == NULL || (!OldBase-&gt;IsQueryCollisionEnabled() &amp;&amp; MovementBaseUtility::IsDynamicBase(OldBase)));
        if ( (bMustJump || !bCheckedFall) &amp;&amp; CheckFall(OldFloor, CurrentFloor.HitResult, Delta, OldLocation, remainingTime, timeTick, Iterations, bMustJump) )
        {
            return;
        }
        bCheckedFall = true;

        // revert this move
        RevertMove(OldLocation, OldBase, PreviousBaseLocation, OldFloor, true);
        remainingTime = 0.f;
        break;
    }
}
else
{
    // Validate the floor check
    if (CurrentFloor.IsWalkableFloor())
    {
        AdjustFloorHeight();
        SetBase(CurrentFloor.HitResult.Component.Get(), CurrentFloor.HitResult.BoneName);
    }
    else if (CurrentFloor.HitResult.bStartPenetrating &amp;&amp; remainingTime &lt;= 0.f)
    {
        // The floor check failed because it started in penetration
        // We do not want to try to move downward because the downward sweep failed, rather we&#39;d like to try to pop out of the floor.
        FHitResult Hit(CurrentFloor.HitResult);
        Hit.TraceEnd = Hit.TraceStart + FVector(0.f, 0.f, MAX_FLOOR_DIST);
        const FVector RequestedAdjustment = GetPenetrationAdjustment(Hit);
        ResolvePenetration(RequestedAdjustment, Hit, UpdatedComponent-&gt;GetComponentQuat());
        bForceNextFloorCheck = true;
    }

    // check if just entered water
    if ( IsSwimming() )
    {
        StartSwimming(OldLocation, Velocity, timeTick, remainingTime, Iterations);
        return;
    }

    // See if we need to start falling.
    if (!CurrentFloor.IsWalkableFloor() &amp;&amp; !CurrentFloor.HitResult.bStartPenetrating)
    {
        const bool bMustJump = bJustTeleported || bZeroDelta || (OldBase == NULL || (!OldBase-&gt;IsQueryCollisionEnabled() &amp;&amp; MovementBaseUtility::IsDynamicBase(OldBase)));
        if ((bMustJump || !bCheckedFall) &amp;&amp; CheckFall(OldFloor, CurrentFloor.HitResult, Delta, OldLocation, remainingTime, timeTick, Iterations, bMustJump) )
        {
            return;
        }
        bCheckedFall = true;
    }
}</code></pre>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/44bddac7-e465-4944-9bd7-2fe770a2fe3f/image.png" alt=""></p>
<p>[*TODO] 32:41 - 42:02 Ledges와 관련된 구현 로직은 추후에 정리하겠습니다 :)</p>
<p>여기서 가장 중요한 부분은 서브 스테핑이다. 더 작은 델타 타임으로 반복을 수행하면 더 정확한 시뮬레이션을 얻을 수 있다는 것을 기억하자.</p>
<h3 id="canprone">CanProne()</h3>
<pre><code class="language-cpp">bool UNyongMovementComponent::CanProne() const
{
    return IsCustomMovementMode(CMOVE_Slide) || IsMovementMode(MOVE_Walking) &amp;&amp; IsCrouching();
}</code></pre>
<p>슬라이드하고 있거나, 웅크리고 있을 때 Prone 모드에 들어갈 수 있다. </p>
<h3 id="enterprone">EnterProne()</h3>
<pre><code class="language-cpp">void UNyongMovementComponent::EnterProne(EMovementMode PrevMode, ECustomMovementMode PrevCustomMode)
{
    bWantsToCrouch = true;

    if (PrevMode == MOVE_Custom &amp;&amp; PrevCustomMode == CMOVE_Slide)
    {
        Velocity += Velocity.GetSafeNormal2D() * ProneSlideEnterImpulse;
    }

    FindFloor(UpdatedComponent-&gt;GetComponentLocation(), CurrentFloor, true, NULL);
}</code></pre>
<p>여기서 <code>FindFloor</code> 를 호출하는 이유는 <code>Enter</code> 함수 이후에 <code>MoveAlongFloor</code> 함수가 바로 호출되기 때문이다. <code>MoveAlongFloor</code> 의 가장 첫 줄을 보면, 현재 바닥이 걸을 수 있는지의 여부를 확인하고 그렇지 않다면 return 해버린다. 만약 갱신하지 않고 무브먼트 모드를 변경한다면, 첫번째 서브 스테핑 단계에서 이동이 실패하고 바닥이 갱신되므로, 첫번째 서브스테핑을 성공시키기 위해 함수를 호출한 것이다.</p>
<h2 id="link">Link</h2>
<pre><code class="language-cpp">bool UNyongMovementComponent::IsMovingOnGround() const
{
    return Super::IsMovingOnGround() || IsCustomMovementMode(CMOVE_Slide) || IsCustomMovementMode(CMOVE_Prone);
}</code></pre>
<p>Prone도 지상에서 움직이는 이동 모드이므로, <code>IsMovingOnGround()</code> 함수에 추가해준다. </p>
<pre><code class="language-cpp">void UNyongMovementComponent::PhysCustom(float deltaTime, int32 Iterations)
{
    Super::PhysCustom(deltaTime, deltaTime);

    switch (CustomMovementMode)
    {
    case CMOVE_Slide:
        PhysSlide(deltaTime, Iterations);
        break;
    case CMOVE_Prone:
        PhysProne(deltaTime, Iterations);
        break;
    default:
        UE_LOG(LogTemp, Fatal, TEXT(&quot;Invalid Movement Mode&quot;));
    }
}</code></pre>
<p><code>PhysCustom</code> 함수에 <code>PhysProne</code> 을 호출해주는 케이스문을 추가해준다.</p>
<pre><code class="language-cpp">virtual void OnMovementModeChanged(EMovementMode PreviousMovementMode, uint8 PreviousCustomMode) override;</code></pre>
<pre><code class="language-cpp">void UNyongMovementComponent::OnMovementModeChanged(EMovementMode PreviousMovementMode, uint8 PreviousCustomMode)
{
    Super::OnMovementModeChanged(PreviousMovementMode, PreviousCustomMode);

    if (PreviousMovementMode == MOVE_Custom &amp;&amp; PreviousCustomMode == CMOVE_Slide) ExitSlide();
    if (PreviousMovementMode == MOVE_Custom &amp;&amp; PreviousCustomMode == CMOVE_Prone) ExitProne();

    if (IsCustomMovementMode(CMOVE_Slide)) EnterSlide(PreviousMovementMode, (ECustomMovementMode)PreviousCustomMode);
    if (IsCustomMovementMode(CMOVE_Prone)) EnterProne(PreviousMovementMode, (ECustomMovementMode)PreviousCustomMode);
}</code></pre>
<p><code>OnMovementModeChanged</code> 함수를 오버라이드하고 구현해준다. 이부분은 강의 이후 수정된 내용이므로, 뒷부분에서 설명한다.</p>
<pre><code class="language-cpp">virtual float GetMaxSpeed() const override;
virtual float GetMaxBrakingDeceleration() const override;</code></pre>
<pre><code class="language-cpp">float UNyongMovementComponent::GetMaxSpeed() const
{
    if (IsMovementMode(MOVE_Walking) &amp;&amp; Safe_bWantsToSprint &amp;&amp; !IsCrouching()) return MaxSprintSpeed;

    if (MovementMode != MOVE_Custom) return Super::GetMaxSpeed();

    switch (CustomMovementMode)
    {
    case CMOVE_Slide:
        return MaxSlideSpeed;
    case CMOVE_Prone:
        return MaxProneSpeed;
    default:
        UE_LOG(LogTemp, Fatal, TEXT(&quot;Invalid Movement Mode&quot;))
        return -1.f;
    }
}

float UNyongMovementComponent::GetMaxBrakingDeceleration() const
{
    if (MovementMode != MOVE_Custom) return Super::GetMaxBrakingDeceleration();

    switch (CustomMovementMode)
    {
    case CMOVE_Slide:
        return BrakingDecelerationSliding;
    case CMOVE_Prone:
        return BrakingDecelerationProning;
    default:
        UE_LOG(LogTemp, Fatal, TEXT(&quot;Invalid Movement Mode&quot;))
            return -1.f;
    }
}</code></pre>
<p>두 개의 함수를 오버라이드 해주자. </p>
<h2 id="trigger">Trigger</h2>
<p>이전 강의에서는 compressed flag를 사용했지만, prone의 경우는 sprint와 다르게 일회성 이벤트이기 때문에 prone을 하고 있는지 여부를 서버에 매 프레임마다 알릴 필요가 없기 때문에 사용할 필요가 없다. 이번 강의에서는 RPC를 이용해 트리거할 예정이다.</p>
<pre><code class="language-cpp">uint8 Saved_bWantsToProne : 1;
bool Safe_bWantsToProne;</code></pre>
<p>커스텀 SavedMove 구조체에 Saved 변수를 선언해주고, 무브먼트 컴포넌트 안에 Safe 변수를 선언해주자.</p>
<pre><code class="language-cpp">void UNyongMovementComponent::FSavedMove_Nyong::SetMoveFor(ACharacter* C, float InDeltaTime, FVector const&amp; NewAccel, FNetworkPredictionData_Client_Character&amp; ClientData)
{
    FSavedMove_Character::SetMoveFor(C, InDeltaTime, NewAccel, ClientData);

    UNyongMovementComponent* CharacterMovement = Cast&lt;UNyongMovementComponent&gt;(C-&gt;GetCharacterMovement());
    Saved_bWantsToSprints = CharacterMovement-&gt;Safe_bWantsToSprint;
    Saved_bPrevWantsToCrouch = CharacterMovement-&gt;Safe_bPrevWantsToCrouch;
    Saved_bWantsToProne = CharacterMovement-&gt;Safe_bWantsToProne;
}

void UNyongMovementComponent::FSavedMove_Nyong::PrepMoveFor(ACharacter* C)
{
    Super::PrepMoveFor(C);
    UNyongMovementComponent* CharacterMovement = Cast&lt;UNyongMovementComponent&gt;(C-&gt;GetCharacterMovement());
    CharacterMovement-&gt;Safe_bWantsToSprint = Saved_bWantsToSprints;
    CharacterMovement-&gt;Safe_bPrevWantsToCrouch = Saved_bPrevWantsToCrouch;
    CharacterMovement-&gt;Safe_bWantsToProne = Saved_bWantsToProne;
}</code></pre>
<p><code>SetMoveFor</code>, <code>PrepMoveFor</code> 함수를 구현해주고,</p>
<pre><code class="language-cpp">void TryEnterProne() { Safe_bWantsToProne = true; }
UFUNCTION(Server, Reliable) void Server_EnterProne();

void UNyongMovementComponent::Server_EnterProne()
{
    Safe_bWantsToProne = true;
}</code></pre>
<p>두 함수를 선언 및 구현해준다. 두 함수 모두 <code>Safe_bWantsToProne</code> 변수를 true 처리하는 것 뿐이라는걸 알 수 있다.</p>
<p><code>Server RPC</code>의 경우, 호출될 때 항상 현재 프레임 movement RPC가 도착하기 전에 도착한다. 따라서 이런 RPC가 호출될 때, 서버의 프레임이 클라이언트의 프레임보다 항상 먼저 실행될 수 있다.</p>
<p>클라이언트가 프레임 100에서 움직임 관련 작업을 수행하고 RPC를 서버에 보내면, 서버는 클라이언트가 보낸 RPC를 처리한 후 프레임 100을 실행한다. 따라서 서버는 프레임을 실행하기 전에 클라이언트가 보낸 움직임 정보로 상태 변수를 미리 업데이트하는 것이 중요하다.</p>
<pre><code class="language-cpp">FTimerHandle TimerHandle_EnterProne;</code></pre>
<pre><code class="language-cpp">void UNyongMovementComponent::CrouchPressed()
{
    bWantsToCrouch = ~bWantsToCrouch;
    GetWorld()-&gt;GetTimerManager().SetTimer(TimerHandle_EnterProne, this, &amp;UNyongMovementComponent::TryEnterProne, ProneEnterHoldDuration);
}

void UNyongMovementComponent::CrouchReleased()
{
    GetWorld()-&gt;GetTimerManager().ClearTimer(TimerHandle_EnterProne);
}</code></pre>
<p>특정 시간 동안 C키를 누른 후 prone을 시작하므로, 타이머 핸들이 필요하다. 타이머 핸들은 서버와 클라이언트에서 다른 속도로 실행되기 때문에 movement safe하지 않다. 하지만 클라이언트만이 Enter Prone을 호출할 것이기 때문에 괜찮다. 클라이언트만이 C키를 실제로 누르고 뗀걸 알 수 있기 때문이다. </p>
<pre><code class="language-cpp">void UNyongMovementComponent::UpdateCharacterStateBeforeMovement(float DeltaSeconds)
{
    // 생략

    if (Safe_bWantsToProne)
    {
        if (CanProne())
        {
            SetMovementMode(MOVE_Custom, CMOVE_Prone);
            if(!CharacterOwner-&gt;HasAuthority()) Server_EnterProne();
        }
        Safe_bWantsToProne = false;
    }

    if (IsCustomMovementMode(CMOVE_Prone) &amp;&amp; !bWantsToCrouch)
    {
        SetMovementMode(MOVE_Walking);
    }

    Super::UpdateCharacterStateBeforeMovement(DeltaSeconds);
}</code></pre>
<p>Prone으로 들어갈 때, 서버가 아닌 경우 Server RPC를 호출한다.</p>
<h2 id="amendments">Amendments</h2>
<h3 id="sprint">Sprint</h3>
<pre><code class="language-cpp">void UNyongMovementComponent::OnMovementUpdated(float DeltaSeconds, const FVector&amp; OldLocation, const FVector&amp; OldVelocity)
{
    Super::OnMovementUpdated(DeltaSeconds, OldLocation, OldVelocity);

    if (MovementMode == MOVE_Walking)
    {
        if (Safe_bWantsToSprint)
        {
            MaxWalkSpeed = Sprint_MaxWalkSpeed;
        }
        else
        {
            MaxWalkSpeed = Walk_MaxWalkSpeed;
        }
    }

    Safe_bPrevWantsToCrouch = bWantsToCrouch;
}</code></pre>
<p>원래는 <code>OnMovementUpdated</code> 함수 내에서 Max Walk Speed를 변경하고 있었다. 이부분을 지우고, </p>
<pre><code class="language-cpp">float UNyongMovementComponent::GetMaxSpeed() const
{
    if (IsMovementMode(MOVE_Walking) &amp;&amp; Safe_bWantsToSprint &amp;&amp; !IsCrouching()) return MaxSprintSpeed;

    // 생략
}</code></pre>
<p>이 코드를 작성해준다. 무브먼트 모드가 Walking이고, Sprint를 원하고, Crouch 상태가 아니라면 <code>MaxSprintSpeed</code> 를 반환해주자. 만약 <code>Safe_bWantsToSprint</code> 상태가 아니라면 Super 함수에서 MaxWalkSpeed를 반환할 것이다. 동일한 이동 모드이지만 다른 최대 속도를 갖을 뿐이기 때문에, 좀 더 깔끔하게 구현할 수 있다.</p>
<h3 id="slide">Slide</h3>
<p>먼저 슬라이드 구현 로직이 일부 변경 및 추가되었다. 자세한 코드는 깃허브를 참고하자.</p>
<pre><code class="language-cpp">void UNyongMovementComponent::EnterSlide()
{
    UE_LOG(LogTemp, Warning, TEXT(&quot;Enter Slide&quot;));

    bWantsToCrouch = true;
    Velocity += Velocity.GetSafeNormal2D() * Slide_EnterImpulse;
    SetMovementMode(MOVE_Custom, CMOVE_Slide);
}</code></pre>
<p>원래 <code>EnterSlide()</code> 함수 내에서 무브먼트 모드가 변경됐지만, 이제는 <code>OnMovementModeChanged()</code> 함수에서 변경된다. </p>
<pre><code class="language-cpp">void UNyongMovementComponent::OnMovementModeChanged(EMovementMode PreviousMovementMode, uint8 PreviousCustomMode)
{
    Super::OnMovementModeChanged(PreviousMovementMode, PreviousCustomMode);

    if (PreviousMovementMode == MOVE_Custom &amp;&amp; PreviousCustomMode == CMOVE_Slide) ExitSlide();
    if (PreviousMovementMode == MOVE_Custom &amp;&amp; PreviousCustomMode == CMOVE_Prone) ExitProne();

    if (IsCustomMovementMode(CMOVE_Slide)) EnterSlide(PreviousMovementMode, (ECustomMovementMode)PreviousCustomMode);
    if (IsCustomMovementMode(CMOVE_Prone)) EnterProne(PreviousMovementMode, (ECustomMovementMode)PreviousCustomMode);
}</code></pre>
<p>이 방법으로 구현하면, 이미 기본 함수인 Set Movement Mode 함수만 호출하면 된다. 따로 EnterSlide 함수를 호출할 필요가 없다. 이유는 두 가지가 있는데, 하나는 우선 깔끔하다. 블루프린트에서 Set Movement mode를 호출할 때 Enter 과 Exit 함수가 자동으로 호출되므로, 두 함수를 블루프린트에 노출하지 않아도 자동으로 불러질 것이다. 두번째로, 슬라이드를 종료하고 falling 모드나 Walking, Swimming 모드에 들어갈 수도 있으므로 Exit Slide 함수가 Movement Mode를 설정하는 것을 원하지 않기 때문이다.</p>
<pre><code class="language-cpp">enum CompressedFlags
{
    FLAG_Sprint        = 0x10,
    FLAG_Custom_1    = 0x20,
    FLAG_Custom_2    = 0x40,
    FLAG_Custom_3    = 0x80,
};</code></pre>
<p>커스텀 Saved Move 구조체 안에 CompressedFlags 만들어 커스텀 플래그를 만들어주었다. 기존 커스텀 플래그와 값이 같다는 것에 주의하자.</p>
<pre><code class="language-cpp">void UNyongMovementComponent::UpdateFromCompressedFlags(uint8 Flags)
{
    Super::UpdateFromCompressedFlags(Flags);

    Safe_bWantsToSprint = (Flags &amp; FSavedMove_Nyong::FLAG_Sprint) != 0;
}</code></pre>
<p>이런식으로 사용할 수 있다. 가독성 말고는 크게 좋은 점은 없지만, 완성도를 높이기 위해 이렇게 했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UE5] Character Movement Component Series : Slide Custom Movement Mode]]></title>
            <link>https://velog.io/@nyong_u_u/UE5-Character-Movement-Component-Series-Slide-Custom-Movement-Mode</link>
            <guid>https://velog.io/@nyong_u_u/UE5-Character-Movement-Component-Series-Slide-Custom-Movement-Mode</guid>
            <pubDate>Mon, 23 Sep 2024 11:21:10 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Character Movement Component In-Depth 강의 시리즈를 공부하면서 한글로 정리한 포스트입니다. 의역과 오역이 난무하니 주의해주세요!
<a href="https://youtu.be/-iaw-ifiUok?si=raM_aBeABXfqFQ98">https://youtu.be/-iaw-ifiUok?si=raM_aBeABXfqFQ98</a></p>
</blockquote>
<h2 id="setup">Setup</h2>
<pre><code class="language-cpp">UENUM(BluprintType)
enum ECustomMovementMode
{
    CMOVE_None        UMETA(Hidden),
    CMOVE_Slide        UMETA(DisplayName = &quot;Slide&quot;),
    CMOVE_MAX        UMETA(Hidden),
};</code></pre>
<p>물리 기반 슬라이드를 구현하기 전, 약간의 준비를 해야한다. 가장 먼저, Custom Movement Mode Enum을 추가하자. 이것은 우리의 커스텀 무브먼트 모드를 추적하고 다양한 무브먼트 모드 사이를 전환하는 데 사용하게 될 것이다.</p>
<pre><code class="language-cpp">UPROPERTY(Transient) class ANyongCharacter* NyongCharacterOwner;

protected:
    virtual void InitializeComponent() override;</code></pre>
<pre><code class="language-cpp">void UNyongMovementComponent::InitializeComponent()
{
    Super::InitializeComponent();

    NyongCharacterOwner = Cast&lt;ANyongCharacter&gt;(GetOwner());
}</code></pre>
<p>다음은 캐릭터를 가져와야 한다. <code>InitializeComponent()</code> 함수를 오버라이드 하여 가져와주자.</p>
<pre><code class="language-cpp">public:
    UFUNCTION(BlueprintPure) bool IsCustomMovementMode(ECustomMovementMode InCustomMovementMode) const;</code></pre>
<pre><code class="language-cpp">bool UNyongMovementComponent::IsCustomMovementMode(ECustomMovementMode InCustomMovementMode) const
{
    return MovementMode == MOVE_Custom &amp;&amp; CustomMovementMode == InCustomMovementMode;
}</code></pre>
<p>우리가 커스텀 무브먼트에 있는지 확인하기 위한 함수를 선언해준다. 먼저 우리가 Custom Movement 상태인지 확인하고, <code>InCustomMovementMode</code> 가 Custom Movement 인지 한번 더 체크한다.</p>
<h2 id="slide-implementation">Slide Implementation</h2>
<pre><code class="language-cpp">private:
    void EnterSlide();
    void ExitSlide();
    void PhysSlide(float deltaTime, int32 Iterations);
    bool GetSlideSurface(FHitResult&amp; Hit) const;</code></pre>
<p>슬라이드 구현을 위해 이렇게 네개의 함수를 구현해야 한다. 나머지는 슬라이드 구현을 위한 보조 함수이지만, <code>PhysSlide</code> 함수의 경우 모든 movement에 필요한 시스템의 실제 부분이다. 모든 movement mode는 해당 movement의 작동 방식을 정의하는 물리 함수가 필요하다. 모든 Phys* 변수는 deltaTime과 Iterations을 매개변수로 갖는다.</p>
<h3 id="getslidesurface">GetSlideSurface</h3>
<pre><code class="language-cpp">FCollisionQueryParams GetIgnoreCharacterParams();</code></pre>
<pre><code class="language-cpp">FCollisionQueryParams ANyongCharacter::GetIgnoreCharacterParams()
{
    FCollisionQueryParams Params;

    TArray&lt;AActor*&gt; CharacterChildren;
    GetAllChildActors(CharacterChildren);
    Params.AddIgnoredActors(CharacterChildren);
    Params.AddIgnoredActor(this);

    return Params;
}</code></pre>
<p>먼저 캐릭터에 캐릭터와 캐릭터의 모든 자식을 무시하는 충돌 쿼리 파라미터를 만들어주는 함수를 만들어준다. (Line Trace가 캐릭터에 닿지 않도록 하기 위해 필요함)</p>
<pre><code class="language-cpp">bool UNyongMovementComponent::GetSlideSurface(FHitResult&amp; Hit) const
{
    FVector Start = UpdatedComponent-&gt;GetComponentLocation();
    FVector End = Start + CharacterOwner-&gt;GetCapsuleComponent()-&gt;GetScaledCapsuleHalfHeight() * 2.f * FVector::DownVector;
    FName ProfileName = TEXT(&quot;BlockAll&quot;);
    return GetWorld()-&gt;LineTraceSingleByProfile(Hit, Start, End, ProfileName, NyongCharacterOwner-&gt;GetIgnoreCharacterParams());
}</code></pre>
<p>슬라이드 할 면을 찾아주는 <code>GetSlideSurface</code> 함수를 만들어준다. 아래로 Line Trace를 쏘고 결과를 반환한다.</p>
<h3 id="physslide">PhysSlide</h3>
<pre><code class="language-cpp">UPROPERTY(EditDefaultsOnly) float Slide_MinSpeed = 350.f;
UPROPERTY(EditDefaultsOnly) float Slide_EnterImpulse = 500.f;
UPROPERTY(EditDefaultsOnly) float Slide_GravityForce = 5000.f;
UPROPERTY(EditDefaultsOnly) float Slide_Friction = 1.3f;</code></pre>
<p>네 가지 파라미터를 추가해주자. 먼저 <code>Slide_MinSpeed</code> 의 경우, 슬라이드의 최소 속도를 나타낸다. 해당 속도보다 낮다면 미끄러질 수 없으며, 슬라이드를 유지할 수도 없다. <code>Slide_EnterImpulse</code> 의 경우, 슬라이드에 들어가자마자 얻을 수 있는 속도의 부스트이다. <code>Slide_GravityForce</code> 는 플레이어가 지면에 서있게 하기 위해 적용되는 힘의 양이며, 또한 슬로프를 따라 미끄러질 때 얼마나 빠르게 속도를 변경하는 지에 대해 영향을 미친다. <code>Slide_Friction</code> 은 슬로프에서 미끄러질 때 얼마나 느리게, 얼마나 빠르게 속도를 떨어지게 할 지 결정한다.</p>
<p>여기서부턴 <code>PhysSlide</code> 의 구현 코드이다.</p>
<pre><code class="language-cpp">if(deltaTime &lt; MIN_TICK_TIME)
{
    return;
}</code></pre>
<p>deltaTime이 MIN_TICK_TIME보다 작지 않은지 확인한다. Default CMC에 적용되어 있는 코드인데, 언리얼은 최소 틱 시간보다 큰 델타 타임을 갖고있지 않으면 물리를 계산하는걸 원하지 않는다. 이 강의의 저자가 추측하건대, 보일러 플레이트인 것 같다고 한다. (<em>boiler plate : 반복적으로 사용하는 코드나 텍스트 조각을 의미한다. 특정 프레임워크를 사용할 때 필수적으로 포함되어야 하는 설정 코드</em>)</p>
<pre><code class="language-cpp">RestorePreAdditiveRootMotionVelocity();</code></pre>
<p>루트모션은 캐릭터 애니메이션 데이터로부터 캐릭터의 위치 및 속도를 제어하는 방식을 의미한다. 슬라이드는 매우 구체적인 움직임이므로 루트 모션이 발생하면 안된다. <code>RestorePreAdditiveRootMotionVelocity();</code> 함수는 루트 모션이 끝난 후, 애니메이션이 적용되기 전의 원래 속도와 방향으로 복원해주는 함수이다.</p>
<pre><code class="language-cpp">FHitResult SurfaceHit;
if (!GetSlideSurface(SurfaceHit) || Velocity.SizeSquared() &lt; pow(Slide_MinSpeed, 2))
{
    ExitSlide();
    StartNewPhysics(deltaTime, Iterations);
    return;
}</code></pre>
<p>먼저, 슬라이드 표면이 없다면 슬라이드에서 빠져나와야 하므로 <code>GetSlideSurface</code> 를 통해 슬라이드 표면이 없는지 확인한다. 또한, 슬라이드 속도가 최소 슬라이드 속도 미만인 경우 슬라이드에서 빠져나와야 하므로, 둘 중 하나라도 해당되면 <code>ExitSlide()</code> 을 호출해 슬라이드를 종료하고 <code>StartNewPhysics()</code> 를 호출한다. <code>StartNewPhysics()</code> 의 경우, <strong>동일한 프레임</strong>에서 새로운 물리 함수를 실행하게 된다.</p>
<pre><code class="language-cpp">// Surface Gravity
Velocity += Slide_GravityForce * FVector::DownVector * deltaTime; // v += a * dt</code></pre>
<p>가장 먼저 할 일은, surface gravity를 적용해 속도를 시간에 따라 점진적으로 업데이트 해주는 것이다. 직접적으로 Velocity에 힘을 더해주는건 아니다.</p>
<pre><code class="language-cpp">// Strafe
if (FMath::Abs(FVector::DotProduct(Acceleration.GetSafeNormal(), UpdatedComponent-&gt;GetRightVector())) &gt; 0.5f)
{
    Acceleration = Acceleration.ProjectOnTo(UpdatedComponent-&gt;GetRightVector());
}
else
{
    Acceleration = FVector::ZeroVector;
}</code></pre>
<p>슬라이드할 때 스트레이프를 허용한다. <code>Acceleration</code> 는 기본적으로 입력 벡터를 의미한다. 캐릭터의 입력이 Strafe(좌우 이동)인지 확인하고, 그 값이 0.5f 이상의 충분히 큰 값일 때만 작동하도록 한다.</p>
<pre><code class="language-cpp">// Calc Velocity
if (!HasAnimRootMotion() &amp;&amp; !CurrentRootMotion.HasOverrideVelocity())
{
    CalcVelocity(deltaTime, Slide_Friction, false, GetMaxBrakingDeceleration());
}
ApplyRootMotionToVelocity(deltaTime);</code></pre>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/19c84308-8f72-494a-a14b-baffa61f9d11/image.png" alt=""></p>
<p>보일러 플레이트 구문이다. 루트 모션이 없으면 실행시키는데, <code>CalcVelocity</code> 는 보일러 플레이트보단 도우미 함수에 가깝다. Default CMC 안에는 Phys* 함수에서 호출할 수 있는 일반적인 작업을 수행하는 헬퍼 함수가 많이 있다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/190bcc26-885e-434e-991c-9e5a55281546/image.png" alt=""></p>
<p>bFluid 변수가 true로 되어있는 것을 볼 수 있는데, 기술적으로 유체 안에 있지 않으면 마찰이 적용되지 않기 때문이다.</p>
<p>그런 다음, <code>ApplyRootMotionToVelocity</code> 함수를 사용해 Velocity에 루트 모션을 적용한다. 이 함수도 마찬가지로, 루트모션을 사용하지 않는다면 의미는 없는 구문이다. (완성도를 위한 코드)</p>
<pre><code class="language-cpp">// Perform Move
Iterations++;
bJustTeleported = false;

FVector OldLocation = UpdatedComponent-&gt;GetComponentLocation();
FQuat OldRotation = UpdatedComponent-&gt;GetComponentRotation().Quaternion();
FHitResult Hit(1.f);
FVector Adjusted = Velocity * deltaTime;
FVector VelPlaneDir = FVector::VectorPlaneProject(Velocity, SurfaceHit.Normal).GetSafeNormal();
FQuat NewRotation = FRotationMatrix::MakeFromXZ(VelPlaneDir, SurfaceHit.Normal).ToQuat();
SafeMoveUpdatedComponent(Adjusted, NewRotation, true, Hit);</code></pre>
<pre><code class="language-cpp">bool SafeMoveUpdatedComponent(
    const FVector&amp; Delta,         // 이동시키려는 변위 (델타 벡터)
    const FQuat&amp; NewRotation,     // 이동시키려는 새로운 회전 (쿼터니언)
    bool bSweep,                  // 충돌 검사를 수행할지 여부
    FHitResult* OutHit = nullptr  // 충돌 결과를 저장할 수 있는 히트 결과 포인터
);</code></pre>
<p><code>SafeMoveUpdatedComponent</code> 함수는, 실제로 캐릭터를 움직이기 위해 호출하는 함수이다. 캡슐의 위치를 직접 변경하는 것이 아니라 이 구문을 호출해야 한다. </p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/b1a67147-69af-4c70-bfd3-935ae63c0384/image.png" alt=""></p>
<p>타겟 위치까지의 이동 경로 상에 무언가와 부딪힌다면, 이동을 멈추거나 다른 처리를 한다. OutHit의 경우, 우리가 스윕했을 때 무언가를 Hit할 경우의 결과가 들어갈 것이다.</p>
<blockquote>
<p>FVector Adjusted = Velocity * deltaTime;</p>
</blockquote>
<p>Velocity는 속도, deltaTime은 프레임 간격이므로 <code>속도 x 시간 = 이동거리</code> 공식에 따라 이동할 거리를 구할 수 있다.</p>
<blockquote>
<p>FVector VelPlaneDir = FVector::VectorPlaneProject(Velocity, SurfaceHit.Normal).GetSafeNormal();</p>
</blockquote>
<p><code>VectorPlaneProject</code> 함수는 Velocity 벡터를 SurfaceHit.Normal 벡터에 수직인 평면으로 투영하는 함수이다. 이걸 통해 현재 표면을 따라 이동할 방향 벡터를 얻을 수 있다. </p>
<blockquote>
<p>FQuat NewRotation = FRotationMatrix::MakeFromXZ(VelPlaneDir, SurfaceHit.Normal).ToQuat();</p>
</blockquote>
<p><code>VelPlaneDir</code> 을 X축으로 하고, <code>SurfaceHit.Normal</code>을 Z축으로 하는 회전 행렬을 생성한다. 이 회전 행렬을 <code>ToQuat</code> 함수를 사용하여 쿼터니언 형태로 변환한다.</p>
<pre><code class="language-cpp">if (Hit.Time &lt; 1.f)
{
    HandleImpact(Hit, deltaTime, Adjusted);
    SlideAlongSurface(Adjusted, (1.f - Hit.Time), Hit.Normal, Hit, true);
}</code></pre>
<p>위 로직에서 문제가 생겼는지 확인하고 처리하는 로직이다. <code>HandleImpact</code> 는 마찬가지로 보일러 플레이트로, 안전한 이동 업데이트의 영향을 처리하기 위해 호출한다. </p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/9e08577b-027a-468d-9c0b-cbdbf396c686/image.png" alt=""></p>
<p><code>SlideAlongSurface</code> 는 정말 유용한 헬퍼 함수이다. 안전한 이동 업데이트를 할 때 무언가에 부딪힐 때까지 움직이는데, 표면에 따라 미끄러지고 있을 때 무엇인가에 부딪히자마자 트랙에서 멈추게 된다. <code>SlideAlongSurface</code> 함수는, 타격을 받고 나서도 벽같은 부딪힌 곳에 평행하게 계속해서 이동하게끔 한다. 벽과 바닥처럼 부딪힌 모든 곳에 해당된다. 표면을 따라 미끄러지는 방법일뿐이며, 어떤 물리 함수에서도 호출할 수 있다. 완전히 움직임에 안전하고 바로 작동한다.</p>
<pre><code class="language-cpp">FHitResult NewSurfaceHit;
if (!GetSlideSurface(NewSurfaceHit) || Velocity.SizeSquared() &lt; pow(Slide_MinSpeed, 2))
{
    ExitSlide();
}</code></pre>
<p>슬라이드 조건이 충족되는지를 다시 확인한다. </p>
<pre><code class="language-cpp">// Update Outgoing Velocity &amp; Acceleration
if (!bJustTeleported &amp;&amp; !HasAnimRootMotion() &amp;&amp; !CurrentRootMotion.HasOverrideVelocity())
{
    Velocity = (UpdatedComponent-&gt;GetComponentLocation() - OldLocation / deltaTime);
}</code></pre>
<p>캐릭터의 속도와 가속도를 업데이트 한다. 현재 위치와 이전 위치의 차이를 <code>deltaTime</code> 으로 나누어, 캐릭터가 지난 프레임 동안 얼마나 이동했는지를 기반으로 속도를 계산한다.</p>
<h3 id="enterslide">EnterSlide</h3>
<pre><code class="language-cpp">void UNyongMovementComponent::EnterSlide()
{
    bWantsToCrouch = true;
    Velocity += Velocity.GetSafeNormal2D() * Slide_EnterImpulse;
    SetMovementMode(MOVE_Custom, CMOVE_Slide);
}</code></pre>
<p>슬라이드를 시작할 때, Crouch 상태를 참으로 한다. 슬라이드를 할 때 여전히 웅크리고 있을 것이기 때문이다. 다음으로 <code>Velocity.GetSafeNormal2D()</code> 를 통해 속도의 수평 요소를 얻고, Slide_EnterImpulse 값을 곱해 속도를 설정해준다. 마지막으로, Movement Mode를 우리가 생성한 CMOVE_Slide로 설정해주면 된다.</p>
<h3 id="exitslide">ExitSlide</h3>
<pre><code class="language-cpp">void UNyongMovementComponent::ExitSlide()
{
    bWantsToCrouch = false;

    FQuat NewRotation = FRotationMatrix::MakeFromXZ(UpdatedComponent-&gt;GetForwardVector().GetSafeNormal2D(), FVector::UpVector).ToQuat();
    FHitResult Hit;
    SafeMoveUpdatedComponent(FVector::ZeroVector, NewRotation, true, Hit);

    SetMovementMode(MOVE_Walking);
}</code></pre>
<p>ExitSlide 도 비슷하다. <code>bWantsToCrouch</code> 를 false로 설정하고, 중간의 세 줄은 회전을 교정한다. 슬라이드가 끝나면, 캡슐을 다시 수직으로 만들어야 하기 때문이다. 마지막으로, 무브먼트 모드를 Walking으로 변경해준다. <code>SafeMoveUpdatedComponent</code> 함수는 safe move movement context에서만 호출해야 한다.</p>
<h2 id="linking-slide-to-system">Linking Slide to System</h2>
<p>이제 지금까지 구현한 슬라이드를 시스템에 연결하는 일이 남았다. </p>
<pre><code class="language-cpp">class FSavedMove_Nyong : public FSavedMove_Character
{
    typedef FSavedMove_Character Super;

    uint8 Saved_bWantsToSprints : 1;
    uint8 Saved_bPrevWantsToCrouch : 1;

    virtual bool CanCombineWith(const FSavedMovePtr&amp; NewMove, ACharacter* InCharacter, float MaxDelta) const override;
    virtual void Clear() override;
    virtual uint8 GetCompressedFlags() const override;
    virtual void SetMoveFor(ACharacter* C, float InDeltaTime, FVector const&amp; NewAccel, class FNetworkPredictionData_Client_Character&amp; ClientData) override;
    virtual void PrepMoveFor(ACharacter* C) override;
};</code></pre>
<p>먼저 SavedMove 클래스 안에 <code>Saved_bPrevWantsToCrouch</code> 를 선언해준다. 이동 로직의 상태에 중요하기 때문이다. 이전 값을 저장하는 이유는, Crouch를 원하는 경우 플래그가 이전에 false고 현재 true인지 또는 그 반대인지를 감지하기 위함이다.</p>
<p>왜 <code>CrouchPressed</code> 함수가 실행됐다는 것을 알기 위해 <code>bWantsToCrouch</code> 가 있음에도 새로운 변수를 만드냐면, 클라이언트가 c키를 누를 때 서버에서 호출되지 않기 때문이다. 다른 시뮬레이트 프록시에서도 호출되지 않는다. 모든 클라이언트와 서버에서  이 동작을 재현할 수 있도록 이전 값을 저장해야 하는 것이다.</p>
<p>safe saved 종류의 아키텍처에 익숙하다면, 저장된 모든 변수에 대해 안전한 변수가 필요하다는 것을 알게 될 것이다.</p>
<pre><code class="language-cpp">bool Safe_bPrevWantsToCrouch;</code></pre>
<p>우리의 실제 CMC 로직에서는 <code>Safe_bPrevWantsToCrouch</code> 변수를 사용하고, 나중에 해당 이동을 생성할 수 있었던 상태를 다시 생성하기 위해 <code>Saved_bPrevWantsToCrouch</code> 에 이 상태를 저장할 것이다.</p>
<pre><code class="language-cpp">void UNyongMovementComponent::FSavedMove_Nyong::SetMoveFor(ACharacter* C, float InDeltaTime, FVector const&amp; NewAccel, FNetworkPredictionData_Client_Character&amp; ClientData)
{
    FSavedMove_Character::SetMoveFor(C, InDeltaTime, NewAccel, ClientData);

    UNyongMovementComponent* CharacterMovement = Cast&lt;UNyongMovementComponent&gt;(C-&gt;GetCharacterMovement());
    Saved_bWantsToSprints = CharacterMovement-&gt;Safe_bWantsToSprint;
    Saved_bPrevWantsToCrouch = CharacterMovement-&gt;Safe_bPrevWantsToCrouch;
}

void UNyongMovementComponent::FSavedMove_Nyong::PrepMoveFor(ACharacter* C)
{
    Super::PrepMoveFor(C);
    UNyongMovementComponent* CharacterMovement = Cast&lt;UNyongMovementComponent&gt;(C-&gt;GetCharacterMovement());
    CharacterMovement-&gt;Safe_bWantsToSprint = Saved_bWantsToSprints;
    CharacterMovement-&gt;Safe_bPrevWantsToCrouch = Saved_bPrevWantsToCrouch;
}</code></pre>
<p>변수를 동기화되도록 하려면 우리는 이것을 <code>SetMoverFor, PrepMoveFor</code> 함수에 추가해야 한다. <code>SetMoveFor</code> 함수에서 값을 저장하고, <code>PrepMoveFor</code> 함수에서 이를 꺼내온다.</p>
<pre><code class="language-cpp">void UNyongMovementComponent::OnMovementUpdated(float DeltaSeconds, const FVector&amp; OldLocation, const FVector&amp; OldVelocity)
{
    Super::OnMovementUpdated(DeltaSeconds, OldLocation, OldVelocity);

    if (MovementMode == MOVE_Walking)
    {
        if (Safe_bWantsToSprint)
        {
            MaxWalkSpeed = Sprint_MaxWalkSpeed;
        }
        else
        {
            MaxWalkSpeed = Walk_MaxWalkSpeed;
        }
    }

    Safe_bPrevWantsToCrouch = bWantsToCrouch;
}</code></pre>
<p><code>OnMovementUpdated</code> 함수는 모든 움직임이 업데이트된 후이기 때문에 변수를 안전하게 설정할 수 있고, 다음 프레임에 적용될 수 있다. 그러므로, 여기서 Safe_bPrevWantsToCrouch 변수에 bWantsToCrouch를 설정해주면 된다.</p>
<pre><code class="language-cpp">virtual void UpdateCharacterStateBeforeMovement(float DeltaSeconds) override;</code></pre>
<p>이제 이 함수를 오버라이드 해주자. 이 함수 내에서 edge detection을 수행하고, 슬라이드 이동 모드로 들어갈 수 있게 된다. 이 함수에서 수행하는 이유는, 여기가 Crouch 매커니즘이 처리되는 곳이므로, Crouch 매커니즘이 업데이트 되기 전에 업데이트 하려면 이 함수에서 업데이트 해야 한다.</p>
<pre><code class="language-cpp">void UNyongMovementComponent::UpdateCharacterStateBeforeMovement(float DeltaSeconds)
{
    if (MovementMode == MOVE_Walking &amp;&amp; !bWantsToCrouch &amp;&amp; Safe_bPrevWantsToCrouch)
    {
        FHitResult PotentialSlideSurface;
        if (Velocity.SizeSquared() &gt; pow(Slide_MinSpeed, 2) &amp;&amp; GetSlideSurface(PotentialSlideSurface))
        {
            EnterSlide();
        }
    }

    if (IsCustomMovementMode(CMOVE_Slide) &amp;&amp; !bWantsToCrouch)
    {
        ExitSlide();
    }

    Super::UpdateCharacterStateBeforeMovement(DeltaSeconds);
}</code></pre>
<p>현재 걷는 중이고, 웅크리기를 원하고 이전에 웅크리고 있던 상태라면(C를 두번 클릭했을 경우) 슬라이드로 들어간다. <code>EnterSlide</code> 안에서 <code>bWantsToCrouch</code> 를 true로 바꿔주므로, 여전히 <code>bWantsToCrouch</code> 의 상태가 된다. 매커니즘이 캡슐 높이를 다시 원래 높이로 올리기 전에 True로 설정한 것이 된다.</p>
<p>커스텀 무브먼트 모드가 슬라이드이고 bWantsToCrouch가 false라면 슬라이드를 종료한다.</p>
<pre><code class="language-cpp">void UCharacterMovementComponent::UpdateCharacterStateBeforeMovement(float DeltaSeconds)
{
    // Proxies get replicated crouch state.
    if (CharacterOwner-&gt;GetLocalRole() != ROLE_SimulatedProxy)
    {
        // Check for a change in crouch state. Players toggle crouch by changing bWantsToCrouch.
        const bool bIsCrouching = IsCrouching();
        if (bIsCrouching &amp;&amp; (!bWantsToCrouch || !CanCrouchInCurrentState()))
        {
            UnCrouch(false);
        }
        else if (!bIsCrouching &amp;&amp; bWantsToCrouch &amp;&amp; CanCrouchInCurrentState())
        {
            Crouch(false);
        }
    }
}</code></pre>
<p>이 모든 것은, Super 이전에 동작해야 한다. Super 함수의 내부 구현을 보면, 여기서 Crouch 매커니즘이 구현된 곳인 것을 알 수 있다.</p>
<pre><code class="language-cpp">virtual void PhysCustom(float deltaTime, int32 Iterations) override;</code></pre>
<pre><code class="language-cpp">void UNyongMovementComponent::PhysCustom(float deltaTime, int32 Iterations)
{
    Super::PhysCustom(deltaTime, deltaTime);

    switch (CustomMovementMode)
    {
    case CMOVE_Slide:
        PhysSlide(deltaTime, Iterations);
        break;
    default:
        UE_LOG(LogTemp, Fatal, TEXT(&quot;Invalid Movement Mode&quot;));
    }
}</code></pre>
<p>이렇게 커스텀 무브먼트 모드에 대한 모든 물리를 다루는 <code>PhysCustom</code> 함수를 오버라이드 해야한다.</p>
<pre><code class="language-cpp">virtual bool IsMovingOnGround() const override;
virtual bool CanCrouchInCurrentState() const override;</code></pre>
<pre><code class="language-cpp">bool UNyongMovementComponent::IsMovingOnGround() const
{
    return Super::IsMovingOnGround() || IsCustomMovementMode(CMOVE_Slide);
}

bool UNyongMovementComponent::CanCrouchInCurrentState() const
{
    return Super::CanCrouchInCurrentState() &amp;&amp; IsMovingOnGround();
}</code></pre>
<p>마지막으로, 두 함수를 오버라이드하여 만들면 된다. 슬라이딩은 지상에서 유효하지만,<code>IsMovingOnGround()</code> 의 Super 함수는 그걸 알 수 없기 때문에 슬라이딩 할때도 <code>IsMovingOnGround()</code> 함수가 true를 반환하도록 해준다.</p>
<p>웅크린 상태의 경우, 공중에서 웅크리기를 원치 않기 때문에 <code>IsMovingOnGround()</code> 도 함께 true일 때만 true를 반환하도록 해준다.</p>
<h2 id="debugging">Debugging</h2>
<p>코드를 똑같이 따라 친 것 같은데 동작이 잘 안된다. </p>
<ol>
<li><p>우선 바닥면 프로파일을 모두 &quot;BlockAll&quot; 로 바꿔줬다.</p>
</li>
<li><p>로그를 찍어보니 Crouch 되는 순간 속도가 줄어들어서, 슬라이드로 진입이 어렵다는 것을 알게 됐다. (Crouch의 Max Speed = 300.f / 슬라이드 진입의 Min Speed = 350.f) Crouch의 Max Speed를 400.f로 올려주었다.</p>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/51c43969-62af-4380-a7c0-a0c0b07933f0/image.gif" alt=""></p>
<p>슬라이드에 진입하고 나니, 이렇게 바로 벽에 박혀버리는 현상이 발생했다.</p>
<pre><code class="language-cpp">if (!bJustTeleported &amp;&amp; !HasAnimRootMotion() &amp;&amp; !CurrentRootMotion.HasOverrideVelocity())
{
    Velocity = (UpdatedComponent-&gt;GetComponentLocation() - OldLocation) / deltaTime;
}</code></pre>
<p>요 코드에서 괄호의 위치를 잘못 작성했었다..ㅎㅎ </p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/e24b649f-1543-4482-9cde-842d0a4f45c0/image.gif" alt=""></p>
<p>이제 슬라이드가 동작하고 동기화도 잘 되는 것을 볼 수 있다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UE5] Character Movement Component Series : Crouch & PlayerCameraManager]]></title>
            <link>https://velog.io/@nyong_u_u/UE5-Character-Movement-Component-Series-Crouch-PlayerCameraManager</link>
            <guid>https://velog.io/@nyong_u_u/UE5-Character-Movement-Component-Series-Crouch-PlayerCameraManager</guid>
            <pubDate>Mon, 23 Sep 2024 05:06:43 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Character Movement Component In-Depth 강의 시리즈를 공부하면서 한글로 정리한 포스트입니다. 의역과 오역이 난무하니 주의해주세요!
<a href="https://youtu.be/vw4sPZ8xhFk?si=h_zshCaMpfvAI4pc">https://youtu.be/vw4sPZ8xhFk?si=h_zshCaMpfvAI4pc</a></p>
</blockquote>
<p>웅크리기 기능은 언리얼에서 제공하는 기능이다. CMC에 내장되어 있으므로 이 기능을 노출하고 프로젝트에서 사용하는 방법을 알려 줄 예정이다. 또한, 카메라 전환을 원할하게 만드는 방법도 보여준다.</p>
<h2 id="crouch-implementation">Crouch Implementation</h2>
<pre><code class="language-cpp">enum CompressedFlags
{
    FLAG_JumpPressed    = 0x01,    // Jump pressed
    FLAG_WantsToCrouch    = 0x02,    // Wants to crouch
    FLAG_Reserved_1        = 0x04,    // Reserved for future use
    FLAG_Reserved_2        = 0x08,    // Reserved for future use

    // Remaining bit masks are available for custom flags.
    FLAG_Custom_0        = 0x10,
    FLAG_Custom_1        = 0x20,
    FLAG_Custom_2        = 0x40,
    FLAG_Custom_3        = 0x80,
};</code></pre>
<p>지난번에 살펴보았던 플래그들이다. 엔진에 예약되어 있는 것이 있고, 우리가 사용할 수 있는 것이 있다. <code>FLAG_WantsToCrouch</code> 를 보면 웅크리기가 이미 있다는 것을 알 수 있다. 만약 이 플래그를 변경하려고 한다면, 자동으로 서버에 복제되고 모든 동작이 동기화되며, CMC가 이 플래그의 실제 구현을 자동으로 다루게 된다. 엔진이 이것을 처리하므로, 우리가 직접 다룰 수는 없다. <code>GetCompressedFlags()</code> 의 Super 함수로 들어가보면 Crouch 플래그를 설정하고 있다는 것을 볼 수 있다.</p>
<p>따라서 <code>bWantsToCrouch</code> 변수를 변경하면, 플래그가 자동으로 설정, 읽혀지고 자동으로 구현된다. </p>
<p>Crouch를 처리할 수 있는 두 가지 방법이 있다. <code>toggle</code> 과 <code>hold</code>.
<code>toggle</code>은 Crouch 버튼을 누를 때마다 상태를 변경하는 것을 의미한다. 웅크리고 있을때는 서 있는 상태가 되고, 서 있는 상태에서는 웅크린다는 뜻이다.
<code>hold</code> 는 Crouch 버튼을 누르고 있으면 웅크린 상태가 되고, 손을 떼자마자 서 있는 상태로 변경되는 것을 말한다.</p>
<pre><code class="language-cpp">UFUNCTION(BlueprintCallable) void CrouchPressed();</code></pre>
<pre><code class="language-cpp">void UNyongMovementComponent::CrouchPressed()
{
    bWantsToCrouch = !bWantsToCrouch;
}</code></pre>
<p>변수를 토글해주고,</p>
<pre><code class="language-cpp">UNyongMovementComponent::UNyongMovementComponent()
{
    NavAgentProps.bCanCrouch = true;
}</code></pre>
<p>생성자에서 Crouch를 사용한다고 명시해준다. 이것이 Crouch 매커니즘을 사용하는 데 필요한 전부이다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/bb1fa385-8ba0-4e0b-8e7e-c04be8dac8eb/image.png" alt=""></p>
<p>마찬가지로 C키를 누르면 플래그를 변경해주는 함수를 호출하고, 디버깅을 위해 캡슐의 Hidden in Game 옵션을 꺼주었다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/bd6ba523-5893-470c-b698-574b17e56341/image.png" alt=""></p>
<p>캡슐의 높이가 줄어들고 속도가 느려지는 것을 확인할 수 있다. 여기서 Crouch를 시작하면 카메라가 즉시 내려간다는 것을 알 수 있는데, 캡슐이 Crouch를 실행하는 순간 절반 높이로 즉시 줄어들어서 발생하는 현상이다.</p>
<p>캡슐의 높이를 천천히 줄이는 방법이 있지만, 이는 많은 비동기화를 유발시킬 수 있다. 서버에서는 충돌이 일어났지만 클라이언트에서는 일어나지 않을 수도 있음. 캡슐의 높이가 둘 중 하나라면 이러한 오류가 발생할 여지가 적다. 이렇기 때문에 캡슐이 스냅되도록 두는 것이 더 나을 것이다.</p>
<p>이러한 로직은 그대로 두고, 일종의 전환이 있는 것처럼 보이도록 카메라와 애니메이션을 통해 부드러운 전환을 처리한다.</p>
<h2 id="playercameramanager-implementation">PlayerCameraManager Implementation</h2>
<pre><code class="language-cpp">#pragma once

#include &quot;CoreMinimal.h&quot;
#include &quot;Camera/PlayerCameraManager.h&quot;
#include &quot;NyongCameraManager.generated.h&quot;

UCLASS()
class NYONG_API ANyongCameraManager : public APlayerCameraManager
{
    GENERATED_BODY()

public:
    ANyongCameraManager();
};</code></pre>
<pre><code class="language-cpp">#include &quot;NyongCameraManager.h&quot;
#include &quot;NyongCharacter.h&quot;
#include &quot;NyongMovementComponent.h&quot;
#include &quot;Components/CapsuleComponent.h&quot;

ANyongCameraManager::ANyongCameraManager()
{
    PrimaryActorTick.bCanEverTick = true;
}</code></pre>
<p>PlayerCameraManager 를 상속받은 새 캐릭터를 만들어주자. </p>
<pre><code class="language-cpp">UPROPERTY(EditDefaultsOnly) float CrouchBlendDurtation = 0.5f;
float CrouchBlendTime;

virtual void UpdateViewTarget(FTViewTarget&amp; OutVT, float DeltaTime) override;</code></pre>
<p>두 개의 변수를 선언해주고, <code>UpdateViewTarget</code> 함수를 오버라이드 한다.</p>
<pre><code class="language-cpp">void ANyongCameraManager::UpdateViewTarget(FTViewTarget&amp; OutVT, float DeltaTime)
{
    Super::UpdateViewTarget(OutVT, DeltaTime);

    if (ANyongCharacter* NyongCharacter = Cast&lt;ANyongCharacter&gt;(GetOwningPlayerController()-&gt;GetPawn()))
    {
        UNyongMovementComponent* NMC = NyongCharacter-&gt;GetNyongCharacterMovement();
        FVector TargetCrouchOffset = FVector(0.f, 0.f, 
            NMC-&gt;GetCrouchedHalfHeight() - NyongCharacter-&gt;GetClass()-&gt;GetDefaultObject&lt;ACharacter&gt;()-&gt;GetCapsuleComponent()-&gt;GetScaledCapsuleHalfHeight());
        FVector Offset = FMath::Lerp(FVector::ZeroVector, TargetCrouchOffset, 
            FMath::Clamp(CrouchBlendTime / CrouchBlendDuration, 0.f, 1.f));

        if (NMC-&gt;IsCrouching())
        {
            CrouchBlendTime = FMath::Clamp(CrouchBlendTime + DeltaTime, 0.f, CrouchBlendDuration);
            Offset -= TargetCrouchOffset;
        }
        else
        {
            CrouchBlendTime = FMath::Clamp(CrouchBlendTime - DeltaTime, 0.f, CrouchBlendDuration);
        }

        if (NMC-&gt;IsMovingOnGround())
        {
            OutVT.POV.Location += Offset;
        }
    }</code></pre>
<pre><code class="language-cpp">NyongCharacter-&gt;GetClass()-&gt;GetDefaultObject&lt;ACharacter&gt;()-&gt;GetCapsuleComponent()-&gt;GetScaledCapsuleHalfHeight());</code></pre>
<p>여기서 캐릭터의 기본 객체를 사용하는 이유는 현재 인스턴스의 상태(웅크리기)와 무관하게 캐릭터가 처음 정의된 상태(기본적으로 서 있는 상태)의 캡슐 컴포넌트 높이를 얻기 위함이다. <code>TargetCrouchOffset</code> 을 계산할 때 웅크린 상태와 서 있는 상태의 기본 높이 차이를 알아야 하므로, 항상 기준이 되는 Default Object 값을 사용하여 변화를 비교한다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/8916931a-6ea3-4d02-89b7-1559c938eecd/image.gif" alt=""></p>
<p>자연스럽게 블렌딩 되는 카메라를 볼 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UE5] Character Movement Component Series : Setup & Sprinting]]></title>
            <link>https://velog.io/@nyong_u_u/UE5-Character-Movement-Component-Series-Setup-Sprinting</link>
            <guid>https://velog.io/@nyong_u_u/UE5-Character-Movement-Component-Series-Setup-Sprinting</guid>
            <pubDate>Sun, 22 Sep 2024 12:11:04 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Character Movement Component In-Depth 강의 시리즈를 공부하면서 한글로 정리한 포스트입니다. 의역과 오역이 난무하니 주의해주세요!
<a href="https://youtu.be/17D4SzewYZ0?si=aBG-uYJkNECFkftK">https://youtu.be/17D4SzewYZ0?si=aBG-uYJkNECFkftK</a></p>
</blockquote>
<h2 id="create-custom-cmc">Create Custom CMC</h2>
<pre><code class="language-cpp">#pragma once

#include &quot;CoreMinimal.h&quot;
#include &quot;GameFramework/CharacterMovementComponent.h&quot;
#include &quot;NyongCharacterMovementComponent.generated.h&quot;

/**
 * 
 */
UCLASS()
class MOVEMENTSTUDY_API UNyongCharacterMovementComponent : public UCharacterMovementComponent
{
    GENERATED_BODY()

};</code></pre>
<p>먼저 캐릭터 무브먼트 컴포넌트를 상속받은 커스텀 무브먼트를 만들어주자. 그 다음, <code>bool Safe_bWantsToSprint</code> 라는 변수를 선언한다. 클라이언트가 Shift 키를 누를 때마다 플래그가 설정될 것이다. 클라이언트 업데이트가 호출되면 모든 것이 복제되고 적절하게 준비된다. (rubber 현상같은 것이 일어나지 않을 것이라는 뜻) 이제 우리가 만들어야 하는 두 개의 도우미 클래스가 있다. </p>
<h2 id="saved-move-class">Saved Move Class</h2>
<pre><code class="language-cpp">UCLASS()
class MOVEMENTSTUDY_API UNyongCharacterMovementComponent : public UCharacterMovementComponent
{
    GENERATED_BODY()

    class FSavedMove_Nyong : public FSavedMove_Character
    {
        typedef FSavedMove_Character Super;

        uint8 Saved_bWantsToSprints : 1;

        virtual bool CanCombineWith(const FSavedMovePtr&amp; NewMove, ACharacter* InCharacter, float MaxDelta) const override;
        virtual void Clear() override;
        virtual uint8 GetCompressedFlags() const override;
        virtual void SetMoveFor(ACharacter* C, float InDeltaTime, FVector const&amp; NewAccel, class FNetworkPredictionData_Client_Character&amp; ClientData) override;
        virtual void PrepMoveFor(ACharacter* C) override;
    };

    bool Safe_bWantsToSprint;
};</code></pre>
<p>첫번째 클래스는 Saved Move이다. 무브먼트 컴포넌트의 모든 상태 데이터 스냅샷을 저장한다. 이 스냅샷은 단일 프레임에 대한 이동을 생성하는데 필요하다. 그런 다음, 서버가 정확한 동작을 수행할 수 있도록 압축된 버전을 서버에 복제한다. </p>
<p><code>Safe_bWantsToSprint</code> 변수는 우리가 스프린트를 원하는지의 여부를 나타낸다. 이 변수는 중요하기 때문에 saved move에 저장해야 한다. 기본 클래스를 재정의해야 하며, 여기에 추가해야 할 것은 저장 될 단일 변수 뿐이다.</p>
<p><code>Safe_bWantsToSprint</code> 는 작동 변수이다. 우리가 설정하고 확인할 것이며, 이 변수의 값을 기반으로 논리를 적용할 것이다.</p>
<p><code>Saved_bWantsToSprints</code> 는 <code>Safe_bWantsToSprint</code> 값으로 자동으로 업데이트 된다. new saved move를 생성하고, safe move를 실행해야 할 때마다 변수가 복사된다.</p>
<p>이제 오버라이드한 함수를 구현하면서, 어떻게 동작하는지 살펴보자.</p>
<h3 id="cancombinewith">CanCombineWith</h3>
<pre><code class="language-cpp">bool UNyongCharacterMovementComponent::FSavedMove_Nyong::CanCombineWith(const FSavedMovePtr&amp; NewMove, ACharacter* InCharacter, float MaxDelta) const
{
    FSavedMove_Nyong* NewNyongMove = static_cast&lt;FSavedMove_Nyong*&gt;(NewMove.Get());

    if (Saved_bWantsToSprints != NewNyongMove-&gt;Saved_bWantsToSprints)
    {
        return false;
    }

    return FSavedMove_Character::CanCombineWith(NewMove, InCharacter, MaxDelta);
}</code></pre>
<p>기본적으로 두 개의 동작을 확인하는 함수이다. 현재의 움직임과 새로운 움직임을 확인하고, 결합할 수 있는지 확인한다. 대역폭을 절약하기 위해서이다.</p>
<p>이 방법의 기본 아이디어는 saved move의 모든 데이터가 거의 동일한지의 여부를 기반으로, 동일하다면 true를 반환한다. 두 번의 움직임을 보낼 필요 없이, 서버에게 두번 실행하라고 요청한다.</p>
<p>먼저 NewMove를 캐스팅하여 커스텀 클래스에 저장한 다음, 현재 <code>Saved_bWantsToSprints</code> 변수가 <code>NewMove</code> 안의 변수와 동일하지 않은지 확인한다. 만약 다르다면, 동작은 정확히 동일하지 않으며 결합할 수 없으므로 false를 리턴한다. 두 값이 동일하다면, Super 함수에서 결합할 수 있는지를 확인하도록 한다. </p>
<h3 id="clear">Clear</h3>
<pre><code class="language-cpp">void UNyongCharacterMovementComponent::FSavedMove_Nyong::Clear()
{
    FSavedMove_Character::Clear();

    Saved_bWantsToSprints = 0;
}</code></pre>
<p>Clear() 함수에서는 Super 함수를 먼저 실행해 기존 값들을 초기화해주고, 추가해준 변수도 초기화해주자.</p>
<h3 id="getcompressedflags">GetCompressedFlags</h3>
<pre><code class="language-cpp">uint8 UNyongCharacterMovementComponent::FSavedMove_Nyong::GetCompressedFlags() const
{
    uint8 Result = Super::GetCompressedFlags();

    if (Saved_bWantsToSprints)
    {
        Result |= FLAG_Custom_0;
    }

    return Result;
}</code></pre>
<p>압축 플래그는 실제로 클라이언트가 이동 데이터를 복제하는 방법이다. 디폴트 SavedMove 안에는 수많은 변수가 있는데, 매번 이 모든 것을 서버에 복제하는 것은 분명히 비쌀 것이다. 우리는 매 프레임마다 이동을 수행하므로, 모두 보내는 대신 매우 최소한으로 압축된 버전을 보내게 된다.</p>
<p>압축된 플래그는 기본적으로 8개이므로 uint8을 반환하고, 압축된 플래그를 사용하여 원하는 모든 것을 할 수 있지만, 일반적으로 각 플래그는 키 누름과 관련되어 있다. 실제로 기본 값을 보면, 점프 키를 누르면 점프 플래그를 플립하는 식이다.</p>
<pre><code class="language-cpp">enum CompressedFlags
{
    FLAG_JumpPressed    = 0x01,    // Jump pressed
    FLAG_WantsToCrouch    = 0x02,    // Wants to crouch
    FLAG_Reserved_1        = 0x04,    // Reserved for future use
    FLAG_Reserved_2        = 0x08,    // Reserved for future use

    // Remaining bit masks are available for custom flags.
    FLAG_Custom_0        = 0x10,
    FLAG_Custom_1        = 0x20,
    FLAG_Custom_2        = 0x40,
    FLAG_Custom_3        = 0x80,
};</code></pre>
<p>우리는 Custom 이라고 적힌 4가지의 플래그를 이용할 수 있다. 압축된 플래그는 클라이언트에서 서버로 전송된다.</p>
<h3 id="setmovefor">SetMoveFor</h3>
<pre><code class="language-cpp">void UNyongCharacterMovementComponent::FSavedMove_Nyong::SetMoveFor(ACharacter* C, float InDeltaTime, FVector const&amp; NewAccel, FNetworkPredictionData_Client_Character&amp; ClientData)
{
    FSavedMove_Character::SetMoveFor(C, InDeltaTime, NewAccel, ClientData);

    UNyongCharacterMovementComponent* CharacterMovement = Cast&lt;UNyongCharacterMovementComponent&gt;(C-&gt;GetCharacterMovement());
    Saved_bWantsToSprints = CharacterMovement-&gt;Safe_bWantsToSprint;
}</code></pre>
<p>CMC의 현재 스냅샷에 저장된 이동을 설정한다. Super 함수를 실행하고, 현재 캐릭터의 무브먼트 컴포넌트를 가져온 뒤, <code>Saved_bWantsToSprints</code> 변수를 <code>Safe_bWantsToSprint</code> 값과 동일하게 설정해준다.</p>
<h3 id="prepmovefor">PrepMoveFor</h3>
<pre><code class="language-cpp">void UNyongCharacterMovementComponent::FSavedMove_Nyong::PrepMoveFor(ACharacter* C)
{
    Super::PrepMoveFor(C);
    UNyongCharacterMovementComponent* CharacterMovement = Cast&lt;UNyongCharacterMovementComponent&gt;(C-&gt;GetCharacterMovement());
    CharacterMovement-&gt;Safe_bWantsToSprint = Saved_bWantsToSprints;
}</code></pre>
<p>PrepMove는 방금 한 것의 정확히 반대 기능을 한다. Saved Move의 데이터를 가져와 CMC의 현재 상태에 적용한다.</p>
<h2 id="client-predicition-data">Client Predicition Data</h2>
<pre><code class="language-cpp">class FNyongNetworkPredictionData_Client : public FNetworkPredictionData_Client_Character
{
    typedef FNetworkPredictionData_Client_Character Super;
public:
    FNyongNetworkPredictionData_Client(const UCharacterMovementComponent&amp; ClientMovement);
    virtual FSavedMovePtr AllocateNewMove() override;
};</code></pre>
<p>이제 Default Character Move를 사용하지 않을 것임을 캐릭터 컴포넌트에 알려야 한다. 커스텀 Save move를 사용하고 이를 수행하는 방법은 <code>network prediction data client</code> 라는 다른 클래스를 통해 이루어지며 이전과 마찬가지로 커스텀 버전으로 만들어야 한다.</p>
<pre><code class="language-cpp">UNyongCharacterMovementComponent::FNyongNetworkPredictionData_Client::FNyongNetworkPredictionData_Client(const UCharacterMovementComponent&amp; ClientMovement)
    : Super(ClientMovement)
{
}

FSavedMovePtr UNyongCharacterMovementComponent::FNyongNetworkPredictionData_Client::AllocateNewMove()
{
    return FSavedMovePtr(new FSavedMove_Nyong());
}</code></pre>
<p>생성자에서는 Super 함수를 호출하기만 하면 되고, <code>AllocateNewMove()</code> 안에서는 새로 만든 Saved Move를 할당하면 된다. </p>
<p>이제 CMC에서 방금 만든 클래스를 사용하고 싶다는 것을 알리기 위해 실제 캐릭터 무브먼트 컴포넌트에 몇 가지 함수를 구현해야 한다.</p>
<pre><code class="language-cpp">virtual FNetworkPredictionData_Client* GetPredictionData_Client() const override;</code></pre>
<pre><code class="language-cpp">FNetworkPredictionData_Client* UNyongCharacterMovementComponent::GetPredictionData_Client() const
{
    check(PawnOwner != nullptr)

    if (ClientPredictionData == nullptr)
    {
        UNyongCharacterMovementComponent* MutableThis = const_cast&lt;UNyongCharacterMovementComponent*&gt;(this);

        MutableThis-&gt;ClientPredictionData = new FNyongNetworkPredictionData_Client(*this);
        MutableThis-&gt;ClientPredictionData-&gt;MaxSmoothNetUpdateDist = 92.f;
        MutableThis-&gt;ClientPredictionData-&gt;NoSmoothNetUpdateDist = 140.f;
    }

    return ClientPredictionData;
}</code></pre>
<p>먼저 <code>ClientPredictionData</code> 가 null인지 확인하고, 없다면 만들어서 반환하고 있다면 이미 가지고 있는 것을 반환한다. 이 값은 클라이언트 예측의 캐시된 값이다. <code>GetPredictionData_Client()</code> 함수는 기본적으로 Getter 이기 때문에 const 함수이다. 하지만 클라이언트 예측 데이터가 null 이라면 우리는 그것을 만들어야 하므로, const_cast를 해준다. 
새로운 <code>FNyongNetworkPredictionData_Client</code> 를 만들어 주자. 밑의 두 줄은, 클라이언트 예측 데이터에 대한 두 개의 매개변수일 뿐이다. </p>
<pre><code class="language-cpp">protected:
    virtual void UpdateFromCompressedFlags(uint8 Flags) override;</code></pre>
<pre><code class="language-cpp">void UNyongCharacterMovementComponent::UpdateFromCompressedFlags(uint8 Flags)
{
    Super::UpdateFromCompressedFlags(Flags);

    Safe_bWantsToSprint = (Flags &amp; FSavedMove_Character::FLAG_Custom_0) != 0;
}</code></pre>
<p>해당 플래그를 기반으로 CMC의 상태를 설정한다.</p>
<h2 id="recap">Recap</h2>
<p>이 모든 설정들이 movement safe한 변수를 생성하고 활용하는 데 필요한 모든 종류의 인프라이므로, 앞으로 일어날 일에 대한 파이프라인을 살펴보자.</p>
<p>먼저, 틱에서 모든 이동 로직을 실행하는 <code>Perform Move</code>를 호출하고, <code>Safe_bWantsToSprint</code> 변수를 활용한다는 내용을 읽고, 안전한 saved move를 생성한 뒤, Set Move를 통해 변수를 읽어낸 후, 이를 <code>Saved_bWantsToSprints</code> 에 저장한다. 그 다음, <code>CanCombineWith</code> 함수를 사용하여 서버로 전송될 대기 중인 이동이 있는지 확인하고 필요하면 결합한 다음, <code>GetCompressedFlags</code> 를 호출하여 저장된 이동을 서버에 보낼 수 있는 작은 네트워크 가능 패킷으로 줄인 다음 서버에게 전송한다.</p>
<p>서버가 이를 수신하면, <code>UpdateFromCompressedFlags</code> 를 호출하여 값을 읽어와 <code>Safe_bWantsToSprint</code> 변수 값을 갱신한 후, 클라이언트가 수행한 이동을 수행한다.</p>
<h2 id="sprint-implementation">Sprint Implementation</h2>
<pre><code class="language-cpp">public:
    UFUNCTION(BlueprintCallable) void SprintPressed();
    UFUNCTION(BlueprintCallable) void SprintReleased();</code></pre>
<p>스프린팅을 구현하기 위해 정의한 이 두가지 함수는, safe move flag를 토글하기만 한다. 무브먼트 컴포넌트의 non-safe 한 컨텍스트에서 호출되고 있는데, 이게 안전한 이동의 속성을 손상시키지 않을까? 여기서 핵심은 
클라이언트에서만 이러한 함수를 호출할 수 있다는 것이다. 클라이언트의 non-safe movement 함수에서 safe 변수를 바꿀 수 있다는 것이 혼란스러울 수 있다. non-safe movement 함수에서 safe 변수를 절대 활용할 수 없으며, 서버에서 safe 변수를 변경하는 non-safe movement 함수를 호출할 수 없다.</p>
<p>이제 완전히 움직임에 안전하며, 클라이언트는 스프린트 pressed 또는 release를 호출하여 이 변수의 값을 토글할 수 있다.</p>
<pre><code class="language-cpp">virtual void OnMovementUpdated(float DeltaSeconds, const FVector&amp; OldLocation, const FVector&amp; OldVelocity) override;</code></pre>
<p><code>OnMovementUpdated</code> 함수는 모든 이동 수행이 끝날 때 자동으로 호출되는 편리한 함수이며, 어떤 이동 모드에 관계 없이 실행되는 일부 이동 로직을 작성할 수 있게 한다.</p>
<pre><code class="language-cpp">UPROPERTY(EditDefaultsOnly) float Sprint_MaxWalkSpeed;
UPROPERTY(EditDefaultsOnly) float Walk_MaxWalkSpeed;</code></pre>
<p>블루프린트에서 편집할 수 있는 두가지 변수를 선언해주자. 스프린트를 사용했을 때 캐릭터가 얼마나 빨리 움직이는지를 결정한다. </p>
<pre><code class="language-cpp">void UNyongCharacterMovementComponent::OnMovementUpdated(float DeltaSeconds, const FVector&amp; OldLocation, const FVector&amp; OldVelocity)
{
    Super::OnMovementUpdated(DeltaSeconds, OldLocation, OldVelocity);

    if (MovementMode == MOVE_Walking)
    {
        if (Safe_bWantsToSprint)
        {
            MaxWalkSpeed = Sprint_MaxWalkSpeed;
        }
        else
        {
            MaxWalkSpeed = Walk_MaxWalkSpeed;
        }
    }
}</code></pre>
<p>이렇게 <code>OnMovementUpdated</code> 함수를 구현해주면, 우리가 <code>MOVE_Walking</code> 에 있는지 확인하고, <code>Safe_bWantsToSprint</code> 변수의 값에 따라 최대값을 설정하는 역할을 한다.</p>
<h2 id="setup-character-for-new-cmc">Setup Character for new CMC</h2>
<pre><code class="language-cpp">protected:
    UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = Movement)
    class UNyongCharacterMovementComponent* NyongMovement;

public:
    AMovementStudyCharacter(const FObjectInitializer&amp; ObejctInitializer);</code></pre>
<p>이제 캐릭터 클래스에 커스텀 무브먼트 변수를 추가해주고, 기본 생성자를 변경해주자. <code>FObjectInitializer</code> 를 사용하면, 커스텀 CMC를 재정의 할 수 있다.</p>
<pre><code class="language-cpp">AMovementStudyCharacter::AMovementStudyCharacter(const FObjectInitializer&amp; ObejctInitializer)
    : Super(ObejctInitializer.SetDefaultSubobjectClass&lt;UNyongCharacterMovementComponent&gt;(ACharacter::CharacterMovementComponentName))
{
    NyongMovement = Cast&lt;UNyongCharacterMovementComponent&gt;(GetCharacterMovement());

// 생략</code></pre>
<p>그리고 이렇게 구현해주면 된다.</p>
<h2 id="testing">Testing</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/c458262e-c569-427c-bf35-41ea949cd1c2/image.png" alt=""></p>
<p>캐릭터 블루프린트로 이동해 이렇게 구현해주고, 스프린트 스피드를 설정해주고 테스트해보자.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/7897437b-fa61-40a9-a737-394f1594a57e/image.png" alt=""></p>
<p>PktLag 를 사용하여 극단적인 지연을 테스트해볼 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UE5] Character Movement Component Series : Architecture]]></title>
            <link>https://velog.io/@nyong_u_u/UE5-Character-Movement-Component-Series-Architecture</link>
            <guid>https://velog.io/@nyong_u_u/UE5-Character-Movement-Component-Series-Architecture</guid>
            <pubDate>Sun, 22 Sep 2024 08:39:43 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><code>Character Movement Component In-Depth</code> 강의 시리즈를 공부하면서 한글로 정리한 포스트입니다. 의역과 오역이 난무하니 주의해주세요!
<a href="https://youtu.be/dOkuIvKCvpg?si=yRzIaylvUKSvZIze">https://youtu.be/dOkuIvKCvpg?si=yRzIaylvUKSvZIze</a></p>
</blockquote>
<h2 id="three-networked-cases">Three Networked Cases</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/ce4aaf66-4f5c-4e64-b26e-7b9154f1c5d1/image.png" alt=""></p>
<p>이해해야 하는 세 가지 네트워크 사례가 있다. 이는 게임의 모든 복제된 개체에 해당된다. 이 원리는 우리가 CMC를 필요로 하는 이유이기 때문에, 반드시 이해해야 한다.</p>
<p>게임에는 동일한 캐릭터의 사본 3개가 존재한다. (소유 클라이언트 / 서버 / 해당 캐릭터를 보기만 하고 제어하지 않는 모든 원격 클라이언트에 대해 하나씩)</p>
<p>소유 클라이언트가 실제로 입력을 받는 클라이언트가 되고, 이동을 수행한 다음, 확인을 위해 서버에 데이터를 보내고 가져오며, 서버에 의해 수정된다.</p>
<p>서버는 클라이언트 데이터를 수신하고, 이 데이터로 이동을 수행하며, 이를 다른 클라이언트로 보내고 필요한 경우 소유 클라이언트를 수정한다.</p>
<p>원격 클라이언트는 서버로부터 상태를 수신하고 해당 위치와 회전을 보간하기만 하면 된다. 하지만, 우리는 <strong>원격 클라이언트에 대해서는 신경 쓸 필요가 없다. CMC에 의해 완전히 처리되기 때문</strong>이다.</p>
<p>우리는 소유 클라이언트와 서버와의 관계에 대해서만 신경쓰면 되기 때문에, 이 강의에서 언급하는 클라이언트는 그 캐릭터의 소유 클라이언트만을 의미한다.</p>
<h2 id="client-side-prediction">Client-Side Prediction</h2>
<p>클라이언트측 예측은 본질적으로 캐릭터 이동 구성 요소의 주요 기능이며, 커스텀 CMC를 사용하는 이유이다. </p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/38c577fd-b927-476a-be34-821ba7c4b6ac/image.png" alt=""></p>
<p>이것은 네트워크 그래프이다. 수직의 선은 시간을 의미하고, 틱 단위로 표현되고 있다. 각 틱은 10밀리초라고 생각하면 된다. (t5 = 50ms / t10 = 100ms) 왼쪽에는 클라이언트가 있고, 오른쪽에는 서버가 있다. 이 그래프는 캐릭터 위치에 대한 클라이언트와 서버의 뷰이다.</p>
<p>t0에서 클라이언트와 서버 모두 0에서 시작하고, 클라이언트는 자신이 원하는 동작을 가지고 있다. 앞으로 한 칸 이동하기 위해 키보드에서 w를 눌러 이 데이터를 서버로 전송하는데, 이 선이 대각선이라는 것을 볼 수 있다. 레이턴시나 핑이 0이 아니기 때문에, t5에서 서버가 이 이동 요청을 받고 이를 적용한다. 클라이언트 캐릭터는 자신의 위치에 대한 권한이 없기 때문에 아직 위치가 업데이트 되지 않은 것을 확인하자. 그 후, 서버는 클라이언트에게 클라이언트가 있어야 할 새로운 위치를 보내고, 클라이언트는 t10에서 받아 이 위치로 이동한다. 따라서, 클라이언트가 키보드에서 w를 누르고, 실제로 캐릭터의 움직임을 볼 수 있는 것은 100ms 지연이 발생한 이후가 된다. 입력에 레이턴시가 있으면 게임이 정말 느리다고 느껴지며, 요즘 게임에서는 이런 현상을 거의 볼 수 없다. </p>
<p>어떻게 해야 올바른 방식으로 이동을 수행할 수 있을까? 클라이언트측 예측이 그걸 가능하게 한다. 클라이언트는 서버에 이동 요청을 하고, 즉시 예측한 이동을 적용시킨다. 하지만, 이것은 단지 예측된 값이기 때문에 권한이 없는 이동이다. 여전히 서버는 0의 위치에 있다.</p>
<p>아무튼, 클라이언트가 이동하고, 이 이동은 서버가 실제로 이동을 적용할 때 일어나는 일과 일치하므로 서버가 이동을 적용하고 정확한 위치를 다시 보냈을 때, 클라이언트와 서버의 위치는 동일하다. 이는 서버가 요청에 따라 수행할 움직임을 성공적으로 클라이언트가 예측했음을 의미한다. </p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/56f96f85-bc9d-4304-9681-f11dd5fa15d2/image.png" alt=""></p>
<p>// 번역 확인 필요..</p>
<p>이 그림에서 볼 수 있는 것은 2D 캐릭터를 제어할때의 모습과 같다. 클라이언트가 실제로 서버보다 앞서 있다는 것이다. 클라이언트가 매 틱마다 이동을 보내고 있었을 수도 있다는 것도 볼 수 있다. 이러한 움직임은 간헐적으로 발생하는 상태로, 서버에서 아직 확인되지 않은 보류중인 움직임이므로 서버가 이러한 움직임 중 하나를 받고 위치와 모든 작업이 완료되면 클라이언트의 움직임을 승인하고 이를 수행한다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/f550fba5-393a-4df2-972b-89fc51ba03ab/image.png" alt=""></p>
<p>캐릭터가 움직일 때마다 움직임을 만드는데, 이것을 Saved Move라고 한다. 기본적으로, 클라이언트는 정확히 같은 움직임을 재현한 모든 상태 데이터를 저장하고 서버가 정확히 동일한 움직임을 다시 생성할 수 있도록 압축된 버전을 서버에 전송한다. Saved Move는 이동을 복제하는데 필요한 모든 데이터가 포함되어 있으며, 여기서 타임스탬프는 움직임의 ID를 식별한다.</p>
<p>클라이언트가 서버가 수행할 이동을 성공적으로 예측하려면, 서버와 클라이언트가 동일하게 호출하는 함수는 정확히 같은 데이터로 호출되어야 하고, 명확해야 한다. 따라서 그 안에 난수나 외부 상태 데이터를 가질 수 없다. 만약 이동 함수가 랜덤 벡터를 호출하고, 클라이언트가 랜덤 벡터로 이동을 수행하지만 서버는 다른 랜덤 벡터를 얻게 되면 서버와 클라이언트가 같은 위치에 있을 수 없다는 것을 알 수 있다. 이로 인해 서버가 클라이언트를 수정해야 하고, 예측이 실패하게 된다.</p>
<h2 id="client-pipeline">Client Pipeline</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/a05cc099-6b22-43f2-bfba-038c8c2703ed/image.png" alt=""></p>
<h3 id="tick-component">Tick Component</h3>
<p>캐릭터가 매 프레임마다 움직여야 하므로, 가장 최근의 입력 벡터를 소비한다. 기본적으로, 모든 키 입력은 클라이언트가 해당 프레임에서 누른 모든 키를 의미한다. 그리고, 캐릭터의 이동 함수 해당 인풋 벡터를 보낸다.</p>
<h3 id="controlled-character-move">Controlled Character Move</h3>
<p>해당 벡터를 가져와 가속도에 적용한다.</p>
<h3 id="perform-move">Perform Move</h3>
<p><code>Controlled Character Move</code> 는 <code>Perform Move</code> 를 호출하고, <code>Perform Move</code> 는 모든 일이 일어나는 곳이다. 캐릭터의 루트 구성 요소에 이동을 적용하는 함수이다. Force와 Impulse를 적용하고, 루트 모션을 업데이트 한다. </p>
<h3 id="start-new-physics">Start New Physics</h3>
<p>다양한 이동 모드에 따라 캐릭터를 움직인다.</p>
<h3 id="replicate-move-to-server">Replicate Move To Server</h3>
<p><code>Controlled Character Move</code> - <code>Perform Move</code> - <code>Start New Physics</code> 에 걸쳐 한 체인이 끝이 나면, 소유 클라이언트는 이 작업을 서버에 전송한다. 여기서 중요한점은 <code>Saved Move</code> 를 만드는 것이다. 이동의 입력과 출력의 결과를 바탕으로 압축된 버전의 데이터 구조(FCharacterNetworkMoveData)를 만들어 서버 RPC 로 보내게 된다.</p>
<h3 id="server-move">Server Move</h3>
<p>서버는 데이터를 전송받고, 클라이언트가 수행한 이동을 재현하게 된다.</p>
<p>우리는 <code>Replicate Move To Server</code> / <code>Server Move</code> 에 관련해서는 신경을 쓰지 않아도 된다. Saved Move만 올바르게 설정해주면, 내부적으로 거의 완벽하게 처리될 것이다.</p>
<p>따라서, 우리는 <code>Perform Move</code>, <code>Start New Physics</code> 함수와 같은 움직임 로직만 집중하면 된다. 하지만, 우리가 이것들을 작성하는 방법과 이 모든 것을 서버에서 동기화하기 위해서는 무슨 일들이 일어나는지 이해해야만 한다.</p>
<h2 id="server-pipeline">Server Pipeline</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/0afeb1a5-0ea4-47fb-a445-1392cf6dd615/image.png" alt=""></p>
<h3 id="server-move-1">Server Move</h3>
<p>클라이언트로부터 RPC로 호출된 Server Move는 들어오는 이동에 대해 준비하는 상태이다. 클라이언트가 작업했던 것과 동일한 입력 매개변수로 작동하도록 서버를 준비시켜야 한다. 동일한 입력에 대해 작업한 것과 동일한 결과를 얻는 것이 매우 중요하다. </p>
<h3 id="move-autonomous">Move Autonomous</h3>
<p>더 많은 준비를 수행하고, 상태를 완전히 준비한 후에는 <code>Perform Move</code> 를 호출한다. </p>
<h3 id="perform-move-1">Perform Move</h3>
<p>클라이언트가 한 것처럼 이동 수행을 호출하기만 하면, 동일한 결과를 얻을 수 있다. </p>
<h3 id="server-move-handle-client-error">Server Move Handle Client Error</h3>
<p>Server Move에서 클라이언트가 보내는 데이터에는, 클라이언트가 Perform move를 호출한 후에 얻은 결과도 들어있다. 여기서는 각각 Perform move를 수행한 후, 서버가 얻은 결과와 클라이언트가 얻은 결과가 동일한지 확인하고, 그렇지 않다면 <code>Client Adjust Position</code> 을 수행하게 된다.</p>
<h3 id="client-adjust-position">Client Adjust Position</h3>
<p>클라이언트의 예측이 틀렸다고 말한 뒤, 실제로 권한이 있는 상태를 보낼 것이다. 모든 것은 서버가 권한을 가지고 있고, 클라이언트는 서버가 보낸 조정 위치를 적용해야 한다.</p>
<h3 id="client-adjust-position-on-client">Client Adjust Position (on Client)</h3>
<p>캐릭터를 서버의 상태로 재설정하고, 보류 중인 움직임들을 다시 시뮬레이션 한다. (추후에 다시 언급)</p>
<h2 id="movement-modes">Movement Modes</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/7c659ad1-dab6-42db-8649-4baa73e2e565/image.png" alt=""></p>
<p><code>Start New Physics</code> 가 호출되었을 때, switch문을 사용하여 무브먼트 모드에 따라 함수를 호출하게 된다. 언리얼은 기본적으로 몇 가지 무브먼트 모드를 제공하며, advanced 한 움직임을 원하지 않을때 사용하거나, 커스텀 무브먼트 모드를 사용할 때의 좋은 예시로써 사용된다. 각 모드에는 입력 매개변수를 사용하여 이동을 계산하는 각각의 물리 함수가 존재한다. 또한 우리는 스위치 문에서 들어갈 수 있는 커스텀 무브먼트와 물리 함수를 직접 구현할 수 있다.</p>
<h2 id="phys-function">Phys Function</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/2c871eb3-bada-48e4-b30c-a7186bf93b2d/image.png" alt=""></p>
<ol>
<li>DeltaTime : 모든 이동은 델타 타임에 걸쳐 동작하며, 델타 타임을 기반으로 일정한 거리만큼 플레이어를 증가시킨다.</li>
<li>Acceleration : 가속도는 본질적으로 입력 벡터이다.</li>
<li>State Data / Compressed Flags : 대시나 그와 비슷한 것을 트리거 한 다음, 압축된 플래그로 설정한다.</li>
</ol>
<p>이것들은 모두 물리 함수가 작동하기 위해 필요하다. 그리고 이것들은 위치, 회전, 속도를 변경하고 CMC의 상태 데이터를 변경시킨다. 또한 언리얼 엔진이 제공하는 도우미 함수도 있다.</p>
<p>델타타임은 tick component에서 나온다. 하지만 이것은 프레임 종속적이며, 동일한 게임을 실행하는 컴퓨터에 따라 다르다. 우리는 이 델타 타임을 더 작은 시뮬레이션 시간 단계로 나눠야 하고, 이것을 서브 스테핑이라고 부른다. </p>
<p>중요한 것은, 시뮬레이션 중간에 이동 모드를 변경할 수 있다는 것이다. 캐릭터가 걷고있다고 해보자. 이 시뮬레이션 중간에 물 볼륨으로 건너가고, 수영을 시작해야 한다. 우리는 이 물리함수 중간에 새로운 물리를 시작(<code>StartNewPhysics</code>)할 수 있다. 이 함수에 원래 델타 시간 절반에 해당하는 남은 시간을 주면, 이터레이션이 증가한다. 이렇게 하면 정밀한 서브 스테핑을 알 수 있고, 훨씬 더 정확한 시뮬레이션을 얻을 수 있다. 그런 다음 남은 델타시간 동안 우리는 수영 물리 모드를 시뮬레이션 하고, 이터레이션을 증가시킬 것이다.  </p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/9ad85f38-c7f0-45eb-9c57-de243c59b40f/image.png" alt=""></p>
<h2 id="clientadjustposition">ClientAdjustPosition()</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/0d0aef41-11a6-46cc-bdc2-affb07875f39/image.png" alt=""></p>
<p>클라이언트측 예측에 대해 다시 이야기 해야 한다. 이것이 작동하려면 두가지 일이 일어나야 한다. 클라이언트는 움직임을 예측해야 하지만, 클라이언트가 실수를 하면 움직임을 수정해야 한다. 서버는 클라이언트가 서버와 일치하는 움직임으로 수정하기 위해 필요한 데이터를 모두 포함한 큰 종류의 봉투를 보내게 된다. 타임 스탬프는 어떤 움직임이 벗어났는지를 식별한다. 여기서는 12.5초가 잘못되었다고 하고 있으므로, 이 위치와 속도를 사용해야 하며, 다른 사항도 알고있어야 한다. 이것이 정확한 움직임이므로, 클라이언트에는 승인되지 않은 보류중인 Saved Move가 있다. 마지막 Saved Move가 14.05초라는 것을 알 수 있는데, 이것은 과장된 것이므로 실제로는 그렇게 큰 차이가 나지는 않을 것이다. 어쨌든, 먼저 발생하는 것은 서버 상태를 캐릭터에 직접 적용하는 것이다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/327e6ddc-5af7-4299-9387-8c2db2f7c908/image.png" alt=""></p>
<p>클라이언트는 서버에 의해 수정될 때마다 서버의 위치로 다시 돌아간다. 알다싶이 14.05초의 시간 단계에서 우리는 잘못된 예측을 했고, 초록색의 원 위치에 있어야 했지만 현재 노란색의 위치에 있다. 하지만 서버가 업데이트된 위치를 보낼 때 우리는 이미 미래에 대해 시뮬레이션 했으며, 14.42 단계인 빨간색 원의 위치에 있다. 우리가 잘못된 예측을 했을 때보다 약 400ms 앞서 있다는 것을 알 수 있다. 클라이언트를 다시 초록색 원의 위치로 보내야 하므로, 꽤 거슬리는 행동이 될 것이다. 빨간색 원과 노란색 원의 사이에 있는 분홍색 원들은 보류 중인 동작을 나타내는데, 이 보류 중인 동작들은 잘못되지 않았다. 단지 노란색 원의 동작이 잘못됐을 뿐이다. </p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/5c5a10d8-1d8f-4f60-b7d9-7bc2f4d89841/image.png" alt=""></p>
<p>그래서 그 대신에, 잘못된 상태를 초록색 원의 상태로 옮긴 다음에, 보류중인 움직임을 우리가 있어야 할 위치로 다시 시뮬레이션 할 것이다. 이렇게 하면 위의 사례보다 훨씬 덜하게 차이가 발생한다는 것을 알 수 있으며, 차이가 특정된 허용범위 내에 있으면 언리얼 엔진이 클라이언트를 새로운 위치에 블렌딩하기에 스냅 현상이 일어나지 않는다. </p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/0d0aef41-11a6-46cc-bdc2-affb07875f39/image.png" alt=""></p>
<p>다시 돌아가서, 우리는 상태를 먼저 적용하고, 보류된 움직임에 대해 다시 적용하고 다시 Perform Move를 호출하면 우리를 수정된 새로운 위치로 데려가게 된다.</p>
<h2 id="statefulness--movement-safety">Statefulness &amp; Movement Safety</h2>
<p>상태 보존과 안전한 이동에 대해 얘기할 것인데, 이 주제는 좀 어려울 수 있어서 코딩을 시작하고 나서야 이해하기가 훨씬 쉬울 것이다.</p>
<p>클라이언트에서 perform move가 호출되는 곳은 두곳이 있다. 실제로 캐릭터를 이동할 때 틱 컴포넌트의 일반적인 이동이 있고, 서버가 클라이언트를 수정할 때 모든 Saved Move에 대한 이동이 있다. 이전에 말했던 것처럼, 이것이 작동하려면 우리는 동일한 입력 상태에서 작동하도록 해야한다. 입력이 동일하게 유지되면, 동일한 결과를 생성할 것이다. </p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/327e6ddc-5af7-4299-9387-8c2db2f7c908/image.png" alt=""></p>
<p>하지만, 상태 데이터가 있으면 까다로워진다. 예를 들면 대시가 될 수 있다. 대시에 쿨다운이 있고, 플레이어가 빨간색 원의 방향으로 돌진했고, 플레이어가 빨간색의 위치에 있다고 해보자. 물리 함수에서 대시 쿨다운에 델타 시간을 추가하고 대시 쿨다운이 대시 지속 시간을 초과할 때마다 다시 대시할 수 있다. </p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/5c5a10d8-1d8f-4f60-b7d9-7bc2f4d89841/image.png" alt=""></p>
<p>조정을 마친 후, Saved Moves를 재생해야 한다. 각 동작이 Perform move를 다시 호출하고, 물리 함수를 다시 호출한 뒤 다시 대시 쿨다운을 증가시킨다.</p>
<p>겉보기에는 문제가 없어보이지만, 위의 단계에선 실시간으로 여러 프레임에 걸쳐 발생하는데 반면 여기서는 저장된 모든 동작을 하나의 프레임에서 다시 시뮬레이션 한다. 이것이 끔찍한 비동기화의 이유이다. 이 문제를 어떻게 해결해야 할까?</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/2229876c-7cae-4866-ac58-eeac69b57972/image.png" alt=""></p>
<p>공식적인 용어는 아니지만, 여기서는 <code>Movement Safety</code> 라는 용어를 사용한다. 이것은 어떤 변수를 사용할 수 있고 없는지에 대한 아이디어와 물리 함수가 비상태 데이터에 대해 오작동하는 것을 피할 수 있는 방법을 제공한다. </p>
<p>Saved Move는 오버라이드가 가능하고, 여기에 대시 쿨다운을 추가할 수 있는 상태 데이터를 추가할 수 있다. 이렇게 하면 Perform move에 대한 입력이 준비되어 처음으로 perform move를 호출했을 때와 정확히 동일해진다.</p>
<p>Saved Move에 상태를 적절하게 저장하지 않으면 이동이 안전하지 않다. 또한 클라이언트와 서버가 동기화되지 않는 비동기화 현상이 발생하며 수많은 서버의 보정이 적용된다는 것을 알 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UE5] Character Movement Component  Series : Intro]]></title>
            <link>https://velog.io/@nyong_u_u/UE5-Character-Movement-Component-Series-Intro</link>
            <guid>https://velog.io/@nyong_u_u/UE5-Character-Movement-Component-Series-Intro</guid>
            <pubDate>Sat, 21 Sep 2024 06:27:33 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><code>Character Movement Component In-Depth</code> 강의 시리즈를 공부하면서 한글로 정리한 포스트입니다. 의역과 오역이 난무하니 주의해주세요!
<a href="https://youtu.be/urkLwpnAjO0?si=ZzIrCFjw58bE8Yy9">https://youtu.be/urkLwpnAjO0?si=ZzIrCFjw58bE8Yy9</a></p>
</blockquote>
<h2 id="in-this-video">In this video...</h2>
<ul>
<li>캐릭터 무브먼트 컴포넌트란 무엇인가?</li>
<li>캐릭터 무브먼트 컴포넌트를 확장해야 하는 이유?</li>
<li>캐릭터 무브먼트 컴포넌트가 제공하는 것은?</li>
</ul>
<h2 id="what-is-the-character-movement-component">What is the Character Movement Component?</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/dd748b98-a01c-4c41-8896-7896771a5372/image.png" alt=""></p>
<p>캐릭터 무브먼트 컴포넌트(이하 CMC)는 언리얼 엔진의 캐릭터를 상속받은 클래스에 포함된 액터의 구성 요소로, 캐릭터에서 전송되는 입력 벡터를 취하고 이를 사용하여 여러 무브먼트 모드가 포함된 캐릭터의 트랜스폼을 다룬다.</p>
<p>이 클래스를 커스텀 CMC로 확장하면 강력한 기능이 발휘된다. 새로운 무브먼트 모드를 만들고, 전력질주와 같은 상태 변경이나 대시와 같은 이벤트처럼 캐릭터가 이동하는 방식을 변경하는 함수를 CMC에 작성할 수 있다.</p>
<p>CMC를 확장하려면 C++이 요구되므로, 블루프린트 프로그래머는 기본 CMC 기능에 가까운 게임을 만드는 것이 좋다. </p>
<h2 id="do-you-need-a-custom-character-movement-component">Do you need a custom Character Movement Component?</h2>
<p>이제 게임에 커스텀 CMC가 필요한지 판단할 수 있는 세 가지 카테고리를 살펴보겠다. </p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/ec28931d-81c4-4f51-91f1-0ba37cb9d5a4/image.png" alt=""></p>
<h3 id="single-player--co-op">Single Player / Co-op</h3>
<p>클라이언트가 권한을 갖고(치트나 핵같은 부분에 신경을 쓰지 않아도 되는 경우) 걷기, 질주, 수영 등과 같은 매우 표준적인 인간형 움직임만 사용하는 경우엔 기본 CMC를 사용하는 것만으로도 충분할 것이다. 이런 경우. CMC에 내장되어 있지 않은 기능은 블루프린트를 약간만 수정하는 것으로도 쉽게 구현할 수 있다. (EX : Max Walk Speed 만 수정하면 되는 스프린팅과 같은 경우)</p>
<p>오프라인 게임에서만 정상적으로 작동한다는 것을 참고하자. 만약 벽 달리기나 행글라이딩과 같은 복잡한 움직임을 사용하고 싶다면, 중력 조절과 같은 기능을 수정하여 가능하게 만들 수는 있지만 벽에 부딪힐 확률이 높으므로 커스텀 CMC를 만드는 것을 추천한다.</p>
<h3 id="general-multiplayer">General Multiplayer</h3>
<p>fps, MMO, 배틀 로얄과 같은 일반적인 멀티 플레이어 게임을 만들고 있다면, 커스텀 CMC를 반드시 사용해야 한다. </p>
<p>다음의 두 가지 경우는 CMC를 확장할 필요가 없을 수도 있다. </p>
<ol>
<li>게임의 움직임이 매우 간단하고 기본 이동 매커니즘 외에 다른 것이 필요하지 않은 경우(멀티플레이어 포인트 앤 클릭 MMO)</li>
<li>CMC에서 처리할 수 없을만큼 복잡한 이동 시스템이 있는 경우(폰의 루트 컴포넌트가 physics-based 여서 완전한 물리 기반 캐릭터일 경우와 같은) </li>
</ol>
<h3 id="non-humanoid">Non-Humanoid</h3>
<p>탱크나 새와 같이 인간형이 아닌 플레이어가 있거나 캐릭터가 물리 기반인 경우.</p>
<p>오프라인 게임을 만든다면 CMC나 캐릭터를 사용하지 않고 폰 클래스를 상속받거나 자신만의 무브먼트 클래스를 만드는 것을 추천한다.</p>
<p>하지만 멀티플레이어 게임을 만든다면, 캐릭터를 사용하고 CMC를 확장하는 것을 추천한다. 비록 플레이어가 이 구조에 적합하지 않다고 느껴지더라도, CMC가 제공하는 장점은 매우 중요하다. 캐릭터 기반 클래스를 사용하는 경우의 유일한 단점은, 캡슐 콜라이더 사용이 고정된 것 뿐이다. 만약 새를 사용하는 경우, 캡슐의 절반 높이를 반경과 동일하게 설정하면 스피어 콜라이더로 만들어지므로 이 문제를 해결할 수 있다.</p>
<h2 id="what-does-the-character-movement-component-provide">What does the Character Movement Component Provide?</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/a0b1144d-96c2-4308-9381-21da722082c5/image.png" alt=""></p>
<blockquote>
<p>Client Side Prediction - hides latency</p>
</blockquote>
<p>CMC의 가장 중요하고 필수적인 기능은 서버에서 신뢰할 수 있고 클라이언트가 예측한다는 것이다. 클라이언트 측 예측과 그것이 필요한 이유에 대해 강의가 올라올 것이다. 멀티플레이 게임을 만들려면, 기본적으로 클라이언트 측 예측이 있어야 한다. 이 기능을 직접 구현하려면 몇 달이 걸릴 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UE5] 데미지 타입별 플레이어 피격 구현]]></title>
            <link>https://velog.io/@nyong_u_u/DAY-46-%EB%8D%B0%EB%AF%B8%EC%A7%80-%ED%83%80%EC%9E%85%EB%B3%84-%ED%94%8C%EB%A0%88%EC%9D%B4%EC%96%B4-%ED%94%BC%EA%B2%A9-%EA%B5%AC%ED%98%84-1-61tnvrzy</link>
            <guid>https://velog.io/@nyong_u_u/DAY-46-%EB%8D%B0%EB%AF%B8%EC%A7%80-%ED%83%80%EC%9E%85%EB%B3%84-%ED%94%8C%EB%A0%88%EC%9D%B4%EC%96%B4-%ED%94%BC%EA%B2%A9-%EA%B5%AC%ED%98%84-1-61tnvrzy</guid>
            <pubDate>Wed, 21 Aug 2024 09:45:10 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/2ff34dea-a6df-414f-9d6b-8966e00f8196/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/72d8c4e0-2b5c-4379-b37f-27947fa91ccc/image.png" alt=""></p>
<p>오늘은 데미지의 종류에 따라 캐릭터가 다르게 반응하도록 구현하려고 한다. 기획서에 명시되어 있는 데미지의 종류는 다운, 넉백, 스턴, 슬로우, 약공격, 강공격이 있다.</p>
<h2 id="데미지-타입-클래스-만들기">데미지 타입 클래스 만들기</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/07ad7e83-3559-4a03-bdca-9c8ce8bdbd91/image.png" alt=""></p>
<p>데미지 타입의 클래스를 이용해 구분하므로, DamageType을 상속받은 클래스를 먼저 만들어주었다. &#39;클래스&#39;만 필요하기 때문에, 코드의 선언부와 구현부는 모두 비워놓았다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/c64fd54a-247f-4807-9178-45f696131c52/image.png" alt=""></p>
<p>약공격과 강공격은 데미지의 세기에 따라 후순위로 분류되므로, 일반 데미지타입이 들어왔을 때 적용하도록 하고 특수한 데미지 타입만 만들어주었다.</p>
<h2 id="takedamage-함수에-데미지-타입-판별-코드-구현">TakeDamage() 함수에 데미지 타입 판별 코드 구현</h2>
<p>기존에 데미지를 처리하는 멀티캐스트 RPC 함수가 데미지 이벤트를 받도록 수정해주고, 타입을 판별해 로그를 출력하도록 만들었다.</p>
<pre><code class="language-cpp">// 데미지 타입별 처리
const UDamageType* DamageType = DamageEvent.DamageTypeClass-&gt;GetDefaultObject&lt;UDamageType&gt;();

if (DamageType-&gt;IsA&lt;UDamageTypeDown&gt;())
{
    UE_LOG(LogTemp, Warning, TEXT(&quot;Damage Type : Down&quot;));
}
else if (DamageType-&gt;IsA&lt;UDamageTypeStun&gt;())
{
    UE_LOG(LogTemp, Warning, TEXT(&quot;Damage Type : Stun&quot;));
}
else if (DamageType-&gt;IsA&lt;UDamageTypeKnockBack&gt;())
{
    UE_LOG(LogTemp, Warning, TEXT(&quot;Damage Type : Knock Back&quot;));
}
else if (DamageType-&gt;IsA&lt;UDamageTypeSlow&gt;())
{
    UE_LOG(LogTemp, Warning, TEXT(&quot;Damage Type : Slow&quot;));
}
else
{
    // 우선 수치는 하드코딩 -&gt; 추후 데이터 테이블로 뺄 것
    if (Damage &lt; 70)
    {
        UE_LOG(LogTemp, Warning, TEXT(&quot;Damage Type : Light Attack&quot;));
    }
    else
    {            
        UE_LOG(LogTemp, Warning, TEXT(&quot;Damage Type : Heavy Attack&quot;));
    }
}</code></pre>
<h2 id="테스트-환경-만들기">테스트 환경 만들기</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/9f194b30-f97a-4644-962c-0522c80865cd/image.png" alt=""></p>
<p>콜리전 박스를 가지고 있는 액터를 하나 만들어주고, 투명한 머테리얼을 가진 메시와 텍스트 렌더러를 달아준 뒤, 데미지 타입을 스트링으로 받도록 변수를 하나 만들어주었다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/967c3ae3-b562-4736-a8b7-1f7328e6f9ad/image.png" alt=""></p>
<p>게임이 시작되면 텍스트 렌더러에 입력한 스트링으로 바꿔주는 코드를 넣고,</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/105e6d69-bf52-4d8e-89f1-f6c262a9f58b/image.png" alt=""></p>
<p>받은 스트링에 따라 데미지를 주도록 노드를 구성했다. 다소 복잡하지만... 각기 다른 클래스를 넣어주어야 해서 어쩔수 없었다...ㅎㅎ</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/10125549-e7ba-4a46-ad77-056e10622958/image.png" alt=""></p>
<p>테스트 레벨에 배치해주고 실행하면,</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/20f4f393-be26-408b-a1f0-4bbfa99e514f/image.gif" alt=""></p>
<p>이렇게 설정해준 클래스 타입별로 로그가 잘 찍히는 것을 볼 수 있다.</p>
<blockquote>
<p>Slow 데미지 타입이 빠지게 되어 Slow 관련 클래스와 테스트 액터를 삭제해주었다.</p>
</blockquote>
<h2 id="몽타주-수정">몽타주 수정</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/24b57733-1cc5-4a12-a6ea-db107c49cc0d/image.png" alt=""></p>
<p>기획서에 명시되어 있는 애니메이션 에셋을 리타게팅 해준 후, 기존 TakeDamage 몽타주에 섹션을 나누어 넣어주었다.</p>
<h2 id="몽타주-재생">몽타주 재생</h2>
<pre><code class="language-cpp">void ATrapperPlayer::MulticastRPCCharacterAlive_Implementation(float Damage, FDamageEvent const&amp; DamageEvent, AActor* DamageCauser)
{
    IsDamaged = true;

    if (IsValid(DamageCauser) &amp;&amp; DamageCauser-&gt;ActorHasTag(&quot;NoCharacterHitAnimationTrap&quot;))
    {
        return;
    }

    GetCharacterMovement()-&gt;SetMovementMode(MOVE_None);

    UAnimInstance* AnimInstance = GetMesh()-&gt;GetAnimInstance();
    if (!IsValid(AnimInstance) &amp;&amp; !IsValid(DamagedAnimationMontage))
    {
        return;
    }

    // 몽타주 재생
    AnimInstance-&gt;Montage_Play(DamagedAnimationMontage, 1.0);

    // 데미지 타입별 애니메이션 섹션 이동
    const UDamageType* DamageType = DamageEvent.DamageTypeClass-&gt;GetDefaultObject&lt;UDamageType&gt;();

    if (DamageType-&gt;IsA&lt;UDamageTypeDown&gt;())
    {
        AnimInstance-&gt;Montage_JumpToSection(FName(&quot;Down&quot;), DamagedAnimationMontage);
    }
    else if (DamageType-&gt;IsA&lt;UDamageTypeStun&gt;())
    {
        AnimInstance-&gt;Montage_JumpToSection(FName(&quot;StunStart&quot;), DamagedAnimationMontage);
    }
    else if (DamageType-&gt;IsA&lt;UDamageTypeKnockBack&gt;())
    {
        AnimInstance-&gt;Montage_JumpToSection(FName(&quot;KnockBack&quot;), DamagedAnimationMontage);
    }
    else
    {
        if (Damage &lt; 70)
        {
            AnimInstance-&gt;Montage_JumpToSection(FName(&quot;Light&quot;), DamagedAnimationMontage);
        }
        else
        {
            AnimInstance-&gt;Montage_JumpToSection(FName(&quot;Heavy&quot;), DamagedAnimationMontage);
        }
    }

    // 몽타주 끝났을 때 델리게이트 바인딩
    FOnMontageEnded EndDelegate;
    EndDelegate.BindUObject(this, &amp;ATrapperPlayer::DamagedEnd);
    AnimInstance-&gt;Montage_SetEndDelegate(EndDelegate, DamagedAnimationMontage);
}</code></pre>
<p>몽타주를 재생한 뒤, 데미지 타입별로 애니메이션 섹션을 바꿔주는 식으로 코드를 구현하였다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/45d326c2-ad3a-4d45-829f-9b4579946a61/image.gif" alt=""></p>
<p>이제 데미지 타입별로 몽타주가 재생되는 것을 볼 수 있다 :)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UE5] 언리얼 UnrealPak 사용해 패키징된 파일 확인하기]]></title>
            <link>https://velog.io/@nyong_u_u/UE5-%EC%96%B8%EB%A6%AC%EC%96%BC-UnrealPak-%EC%82%AC%EC%9A%A9%ED%95%B4-%ED%8C%A8%ED%82%A4%EC%A7%95%EB%90%9C-%ED%8C%8C%EC%9D%BC-%ED%99%95%EC%9D%B8%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@nyong_u_u/UE5-%EC%96%B8%EB%A6%AC%EC%96%BC-UnrealPak-%EC%82%AC%EC%9A%A9%ED%95%B4-%ED%8C%A8%ED%82%A4%EC%A7%95%EB%90%9C-%ED%8C%8C%EC%9D%BC-%ED%99%95%EC%9D%B8%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 21 Aug 2024 05:49:15 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>SubTitle : 난 데이터 테이블 누락인줄 알았어</p>
</blockquote>
<pre><code class="language-cpp">static ConstructorHelpers::FObjectFinder&lt;UDataTable&gt; CharacterTable(TEXT(&quot;/Script/Engine.DataTable&#39;/Game/Blueprints/Data/DT_CharacterData.DT_CharacterData&#39;&quot;));
if (CharacterTable.Succeeded() &amp;&amp; CharacterTable.Object)
{
    CharacterData = CharacterTable.Object;
}</code></pre>
<p>캐릭터 데이터 테이블을 생성자에서 FObjectFinder를 사용해 불러오고, BeginPlay() 함수에서 캐릭터 스탯의 초기화에 사용하고 있는데, 에디터에서는 정상적으로 작동하지만 빌드만 뽑으면 자꾸 Fatal Error가 뜨면서 시작하자마자 게임이 꺼지는 현상이 있었다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/eaff2cb8-db8b-46f0-9fc0-586613873043/image.png" alt=""></p>
<pre><code class="language-cpp">[2024.08.21-05.18.18:011][638]LogWindows: Error: === Critical error: ===
[2024.08.21-05.18.18:011][638]LogWindows: Error: 
[2024.08.21-05.18.18:011][638]LogWindows: Error: Unhandled Exception: EXCEPTION_ACCESS_VIOLATION writing address 0x00000000000008b0
[2024.08.21-05.18.18:011][638]LogWindows: Error: 
[2024.08.21-05.18.18:011][638]LogWindows: Error: [Callstack] 0x00007ff632815df1 TrapperProject.exe!ATrapperPlayer::ApplyCharacterData() [E:\GA5thFinalProject_Trapper\5_Project\TrapperProject\Source\TrapperProject\Character\TrapperPlayer.cpp:710]</code></pre>
<p>ApplyCharacterData() 여기서 자꾸 예외가 뜨길래 데이터 테이블이 패키징에 포함되지 않는 것 같았다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/f489aa4f-7e4e-4181-bec1-85e46c0fe96f/image.png" alt=""></p>
<p>분명 Additional Asset Directories to Cook 에 디렉토리 경로를 잘 넣어주고 있는데... 그래서 패키징된 파일에 데이터 테이블이 잘 들어가있는지 확인이 필요했다. 그 확인하는 과정을 남겨두면 좋을 것 같아서 기록하는 포스팅.</p>
<h2 id="unrealpak-사용하는법">UnrealPak 사용하는법</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/0a32369e-c19c-4d15-be6a-3ba85e0881ac/image.png" alt=""></p>
<p>먼저 명령 프롬포트를 킨 뒤, UnrealPak이 설치된 폴더로 이동하자.</p>
<blockquote>
<p>C:\Program Files\Epic Games\UE_5.3\Engine\Binaries\Win64</p>
</blockquote>
<p>그리고 패키징된 파일이 있는 곳으로 이동해서 Pak 폴더를 찾아준다.</p>
<blockquote>
<p>(생략)\Windows\TrapperProject\Content\Paks</p>
</blockquote>
<p>내 경우엔 여기에 위치한다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/fe509e9f-0bef-4707-b546-4fea8b170d8d/image.png" alt=""></p>
<p>UE5 기준으로 이렇게 세개의 파일을 찾을 수 있었다.</p>
<p>만약 포함된 에셋의 리스트만 출력하고 싶다면, 명령 프롬포트에</p>
<blockquote>
<p>UnrealPak.exe &quot;출력해야 하는 Pak 파일 경로&quot; -List</p>
</blockquote>
<p>이렇게 써주면 된다.</p>
<pre><code class="language-cpp">C:\Program Files\Epic Games\UE_5.3\Engine\Binaries\Win64&gt;UnrealPak.exe &quot;E:(생략)\Windows\TrapperProject\Content\Paks\TrapperProject-Windows.pak&quot; -List</code></pre>
<p>(예시 커맨드)</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/a6e673e7-e218-4eb4-b090-20c605e2d4df/image.png" alt=""></p>
<p>그럼 이렇게 쭉쭉 출력된다.</p>
<p>만약 압축을 풀어서 폴더로 확인하고 싶다면,</p>
<blockquote>
<p>UnrealPak.exe &quot;출력해야 하는 Pak 파일 경로&quot; -Extract &quot;압축 풀 경로&quot;</p>
</blockquote>
<p>이렇게 넣어주면 된다.</p>
<pre><code class="language-cpp">C:\Program Files\Epic Games\UE_5.3\Engine\Binaries\Win64&gt;UnrealPak.exe &quot;E:(생략)\Windows\TrapperProject\Content\Paks\TrapperProject-Windows.ucas&quot; -Extract &quot;C:\Users\user\Desktop\PakTest&quot;</code></pre>
<p>(예시 커맨드)</p>
<p>나는 pak 확장자가 아닌 ucas 확장자를 압축해제 해줬는데, pak을 풀어봤을 때 에셋이 포함되어 있지 않았기 때문.. Content 폰트만 들어있었어서, 용량이 더 큰 ucas를 풀어보니 거기에 에셋이 다 있었다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/d179ee19-ae1a-415b-a12a-6b008d98181b/image.png" alt=""></p>
<p>이렇게.. Extract 한 폴더에 들어가보면 쭉쭉 뜨기 때문에, 에셋이 누락되었는지 다른 문제인건지 확인할 수 있게된다.</p>
<p>아무튼 사진을 보면 알 수 있듯, 나는... 다른 문제였다..
데이터 테이블 너무 예쁘게 들어가있구.. 애먼 데이터 테이블만 탓하고 있었음 :)
뭐 그래도 덕분에 UnrealPak 사용법도 알게되고 럭키비키자나...!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UE5] 시퀀스 & 다이얼로그 매니저 구현 / 데이터 시트 설명서 제작]]></title>
            <link>https://velog.io/@nyong_u_u/DAY-44-45-%EC%8B%9C%ED%80%80%EC%8A%A4-%EB%8B%A4%EC%9D%B4%EC%96%BC%EB%A1%9C%EA%B7%B8-%EB%A7%A4%EB%8B%88%EC%A0%80-%EA%B5%AC%ED%98%84-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%8B%9C%ED%8A%B8-%EC%84%A4%EB%AA%85%EC%84%9C-%EC%A0%9C%EC%9E%91</link>
            <guid>https://velog.io/@nyong_u_u/DAY-44-45-%EC%8B%9C%ED%80%80%EC%8A%A4-%EB%8B%A4%EC%9D%B4%EC%96%BC%EB%A1%9C%EA%B7%B8-%EB%A7%A4%EB%8B%88%EC%A0%80-%EA%B5%AC%ED%98%84-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%8B%9C%ED%8A%B8-%EC%84%A4%EB%AA%85%EC%84%9C-%EC%A0%9C%EC%9E%91</guid>
            <pubDate>Tue, 06 Aug 2024 08:09:48 GMT</pubDate>
            <description><![CDATA[<h2 id="시퀀스-매니저-구현">시퀀스 매니저 구현</h2>
<p>시퀀스들의 정보를 가지고 있고, 시퀀스마다 타입을 붙혀준 뒤 그 타입을 인자로 받아 재생시켜주는 시퀀스 매니저를 만들어주었다.</p>
<pre><code class="language-cpp">UENUM(BlueprintType)
enum class ESequenceType : uint8
{
    Opening                            UMETA(DisplayName = &quot;Opening&quot;),
    TutorialCameraMove                UMETA(DisplayName = &quot;TutorialCameraMove&quot;),
    FirstWave                        UMETA(DisplayName = &quot;FirstWave&quot;),
    BonusWave                        UMETA(DisplayName = &quot;BonusWave&quot;),
    Ending                            UMETA(DisplayName = &quot;Ending&quot;),
};</code></pre>
<p>enum class를 사용해 각각의 시퀀스마다 이름을 붙혀주었다.</p>
<pre><code class="language-cpp">UPROPERTY(EditDefaultsOnly, BlueprintReadWrite)
TMap&lt;ESequenceType, class ULevelSequence*&gt; SequenceMap;</code></pre>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/247c9184-1605-4141-9f82-6f15c52b55e0/image.png" alt=""></p>
<p>맵을 만들어주고, 블루프린트에서 직접 시퀀스를 설정할 수 있도록 해줬다.</p>
<pre><code class="language-cpp">UFUNCTION(NetMulticast, Reliable)
void MulticastRPCPlaySequence(ESequenceType Type);</code></pre>
<pre><code class="language-cpp">void ASequenceManager::MulticastRPCPlaySequence_Implementation(ESequenceType Type)
{
    ULevelSequence** Sequence = SequenceMap.Find(Type);
    if (Sequence &amp;&amp; *Sequence)
    {
        if (HasAuthority())
        {
            ATrapperGameState* TrapperGameState = GetWorld()-&gt;GetGameState&lt;ATrapperGameState&gt;();
            TrapperGameState-&gt;OnEventExecute.Broadcast(99);
        }

        FMovieSceneSequencePlaybackSettings PlaybackSettings;
        LevelSequencePlayer = ULevelSequencePlayer::CreateLevelSequencePlayer(GetWorld(), *Sequence, PlaybackSettings, LevelSequenceActor);

        if (LevelSequencePlayer &amp;&amp; LevelSequenceActor)
        {
            // 종료 함수 바인딩
            if (HasAuthority())
            {
                FName* EndFunctionName = EndFunctionMap.Find(Type);
                if (EndFunctionName)
                {
                    LevelSequencePlayer-&gt;OnFinished.AddDynamic(this, &amp;ASequenceManager::OnSequenceFinished);
                }
            }

            LevelSequencePlayer-&gt;Play();
        }
    }
}</code></pre>
<p>멀티캐스트 RPC 함수를 사용해 시퀀스 타입을 인자로 받아 시퀀스를 재생시켜주는 함수이다. 시퀀스 맵에서 타입에 따라 맞는 시퀀스를 불러와주고, 종료 함수를 바인딩한 뒤 시퀀스를 플레이한다. 서버일 경우 이벤트 코드 99번을 송신하는데, 플레이어 HUD를 숨김처리해주는 코드이다.</p>
<pre><code class="language-cpp">void ASequenceManager::OnSequenceFinished()
{
    for (const TPair&lt;ESequenceType, FName&gt;&amp; Pair : EndFunctionMap)
    {
        if (LevelSequencePlayer-&gt;GetSequence() == SequenceMap[Pair.Key])
        {
            // 함수 이름을 통해 호출
            FName FunctionName = Pair.Value;
            UFunction* Function = FindFunction(FunctionName);
            if (Function)
            {
                ProcessEvent(Function, nullptr);

                ATrapperGameState* TrapperGameState = GetWorld()-&gt;GetGameState&lt;ATrapperGameState&gt;();
                TrapperGameState-&gt;OnEventExecute.Broadcast(98);
            }
        }
    }
}</code></pre>
<p>시퀀스가 종료되었을 때 호출되는 함수이다. <code>ProcessEvent</code> 함수를 통해 실행된 시퀀스에 따라 시퀀스가 끝났을 때 각기 다르게 처리해준다.</p>
<pre><code class="language-cpp">// ASequenceManager.h

TMap&lt;ESequenceType, FName&gt; EndFunctionMap;
UFUNCTION() void OpeningSequenceFinished();
UFUNCTION() void TutorialCameraMoveFinished();
UFUNCTION() void FirstWaveFinished();
UFUNCTION() void BonusWaveFinished();

// ASequenceManager.cpp

void ASequenceManager::Initialize()
{
    EndFunctionMap.Add(ESequenceType::Opening, TEXT(&quot;OpeningSequenceFinished&quot;));
    EndFunctionMap.Add(ESequenceType::TutorialCameraMove, TEXT(&quot;TutorialCameraMoveFinished&quot;));
    EndFunctionMap.Add(ESequenceType::FirstWave, TEXT(&quot;FirstWaveFinished&quot;));
    EndFunctionMap.Add(ESequenceType::BonusWave, TEXT(&quot;BonusWaveFinished&quot;));
}</code></pre>
<p><code>Initialize()</code> 함수 안에서 EndFunctionMap에 호출해야 할 함수명들을 저장해둔다. 사실 모든 함수에서 이벤트 코드만 호출하기 때문에 이렇게 복잡하게 짤 필요는 없었지만, 다른 방식으로도 한번 설계해 보았다. 이벤트 코드로 호출하는 것과는 다르게, 시퀀스마다 이름을 지정해주었기 때문에 호출할때 헷갈리지 않아서 좋은 것 같다 :)</p>
<p>다시 위로 올라가서 시퀀스가 종료되어 <code>OnSequenceFinished()</code> 함수가 호출되면, 각 시퀀스에 맞는 종료 함수를 실행해주고, 98번 이벤트 코드를 전송해 플레이어 HUD를 다시 되돌려놓는다.</p>
<h2 id="다이얼로그-매니저-구현">다이얼로그 매니저 구현</h2>
<pre><code class="language-cpp">TObjectPtr&lt;class UDataTable&gt; DialogData;

ADialogManager::ADialogManager()
{
    // Set this actor to call Tick() every frame.  You can turn this off to improve performance if you don&#39;t need it.
    PrimaryActorTick.bCanEverTick = true;

    static ConstructorHelpers::FObjectFinder&lt;UDataTable&gt; DialogTable(TEXT(&quot;/Script/Engine.DataTable&#39;/Game/Blueprints/Data/DT_DialogData.DT_DialogData&#39;&quot;));
    if (DialogTable.Succeeded() &amp;&amp; DialogTable.Object)
    {
        DialogData = DialogTable.Object;
    }
}</code></pre>
<p>먼저, 생성 시점에 다이얼로그 데이터 테이블을 불러와줬다.</p>
<pre><code class="language-cpp">    /// --------------------------------
    ///                Dialog 
    /// --------------------------------

    if (EventCode &gt;= 500 &amp;&amp; EventCode &lt; 600)
    {
        DialogManager-&gt;MulticastRPCPlayDialog(EventCode);
    }</code></pre>
<p>게임모드에서 500번대 이벤트 코드를 받으면 <code>MulticastRPCPlayDialog()</code> 함수를 호출한다.</p>
<pre><code class="language-cpp">void ADialogManager::MulticastRPCPlayDialog_Implementation(int32 DialogCode)
{
    if (!PlayerController)
    {
        return;
    }

    PlayDialog(DialogCode);
}</code></pre>
<p>플레이어 컨트롤러가 있는 경우에만(BeginPlay 함수에서 받아온다) <code>PlayDialog()</code> 함수를 호출한다.</p>
<pre><code class="language-cpp">void ADialogManager::PlayDialog(int32 DialogCode)
{
    // 플레이어 대사 UI 활성화
    PlayerController-&gt;ShowPlayerDialog(true);
    bIsPlaying = true;

    DialogIndex++;

    // 다이얼로그 데이터를 불러오기 위한 이름 계산
    FString DialogDataName = FString::FromInt(DialogCode) + TEXT(&quot;_&quot;) + FString::FromInt(DialogIndex);
    FDialogInfo* Dialog = DialogData-&gt;FindRow&lt;FDialogInfo&gt;(*DialogDataName, FString());
    LastDialogCode = DialogCode;

    // 데이터가 있다면,
    if (Dialog)
    {
        // Text UI를 설정해준다.
        PlayerController-&gt;SetPlayerText(GetCharacterName(Dialog-&gt;Character), Dialog-&gt;Dialog);

        // 다이얼로그에 이벤트 코드가 있을 경우 실행
        if (Dialog-&gt;EventCode.Num() != 0 &amp;&amp; HasAuthority())
        {
            for (auto Code : Dialog-&gt;EventCode)
            {
                GetWorld()-&gt;GetGameState&lt;ATrapperGameState&gt;()-&gt;OnEventExecute.Broadcast(Code);
            }
        }

        // 마지막 대사의 경우 타이머를 설정한 뒤 다이얼로그 재생을 끝낸다
        if (Dialog-&gt;bIsEnd)
        {
            FTimerHandle EndHandle;
            GetWorldTimerManager().SetTimer(EndHandle, FTimerDelegate::CreateLambda([&amp;]
                {    
                    // 대사 UI 비활성화, 인덱스 초기화
                    PlayerController-&gt;ShowPlayerDialog(false);
                    DialogIndex = 0;
                    bIsPlaying = false;
                }
            ), 1.0f, false, Dialog-&gt;Time);
        }
        // 마지막 대사가 아닐 경우, 다음 대사 진행
        else
        {
            FTimerHandle Handle;
            GetWorldTimerManager().SetTimer(Handle, FTimerDelegate::CreateLambda([&amp;]
                {
                    PlayDialog(LastDialogCode);
                }
            ), 1.0f, false, Dialog-&gt;Time);
        }
    }
}</code></pre>
<p>이것도 이쁘게 주석으로 정리해보았다..! 다이얼로그 매니저는 호출 당했을 때 저장하고 있는 대사만 출력해주면 되므로 간단하게 끝난다 :)</p>
<h2 id="이벤트-작동-테스트">이벤트 작동 테스트</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/9e1135ba-7493-42c9-9d12-395a331e8366/image.gif" alt=""></p>
<p>처음 설계대로 게임모드가 시퀀스 매니저와 퀘스트 매니저, 다이얼로그 매니저에서 보내준 이벤트 코드를 받아 전역으로 처리해주며 게임의 루프가 굴러가고 있다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/af16d9a6-ae88-42f7-8040-4b3e16624f59/image.png" alt=""></p>
<p>튜토리얼~첫번째 웨이브까지의 이벤트코드 수신 목록이다. 코드를 받는대로 잘 작동하는걸 확인할 수 있었다 :) 이제 틀은 모두 만들어졌으니, 이런식으로 쭉쭉 살을 붙혀나가면 메타루프도 금방 끝날 것 같다.</p>
<h2 id="데이터-시트-설명서-제작">데이터 시트 설명서 제작</h2>
<p>기획자분들이 웨이브 / 퀘스트 / 다이얼로그 관련해 수정할 수 있도록 설명서를 제작했다(드디어!).</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/2ee022ca-8366-4095-971f-403662851c44/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/8f730856-b4eb-40ad-adcc-137682ff12b9/image.png" alt=""></p>
<p>예시와 함께 최대한 쉽게 설명하려고 노력해서 작성했다..!!</p>
<p><img src="blob:https://velog.io/c4b5025a-fd5c-46fa-b943-2f38814a5655" alt="업로드중.."></p>
<p>그리고 내 개인 노트에 정리해두었던 퀘스트 / 이벤트 코드 관련된 것들도 같이 작성해두었다. 겸사겸사 현재 적용 여부도 기획분들이 확인하실 수 있게끔 함께 정리해두었음 :)</p>
<blockquote>
<p><a href="https://docs.google.com/spreadsheets/d/18-yvMouVCjKCmvQdG71NDvYpjYNiS3YTwLNt99iZ3dw/edit?usp=sharing">[Trapper] 웨이브 &amp; 퀘스트 &amp; 다이얼로그 코드 정리 및 설명서</a></p>
</blockquote>
<p>궁금하실분을 위해 준비했습니다..(뾰롱)</p>
<hr>
<h2 id="뜬금일기">(뜬금)일기</h2>
<p>갈수록 정리의 개념으로 글을 쓰고있게 되는 것 같다. <del>빨리 굴러가게 구현해야하니까 글 쓸 시간이..</del>
과정을 남기는게 목표긴 했는데, 그래도 이렇게라도 남겨두는게 어디냐!!! 앞으로도 열심히 써야지 :)</p>
<p>프로젝트를 진행할 수록, 회사에서의 프로젝트 진행이 너무너무 궁금하다..
얼마나 체계적일까? 내가 설계하는게 효율적인지 비효율적인지 판단해줄 선배들이 잔뜩 계시는걸까..?!
기존 게임들은 도대체 어떻게 게임의 흐름을 관리하고, 기획자와 협업하고, 시스템을 만들고 그러는걸까..
너무너무너무 궁금하다 XD 빨리 배우고싶어!!!!!!!!(시끄러움)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UE5] 게임 모드에서 이벤트 코드 제어]]></title>
            <link>https://velog.io/@nyong_u_u/DAY-43-%EA%B2%8C%EC%9E%84-%EB%AA%A8%EB%93%9C%EC%97%90%EC%84%9C-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EC%BD%94%EB%93%9C-%EC%A0%9C%EC%96%B4</link>
            <guid>https://velog.io/@nyong_u_u/DAY-43-%EA%B2%8C%EC%9E%84-%EB%AA%A8%EB%93%9C%EC%97%90%EC%84%9C-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EC%BD%94%EB%93%9C-%EC%A0%9C%EC%96%B4</guid>
            <pubDate>Tue, 30 Jul 2024 12:05:34 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/d86034f2-4903-497c-8090-0525de2b93f6/image.png" alt=""></p>
<p>우선, 이런식으로 내가 기획문서를 읽고 작동시켜야 할 이벤트들을 정리해보았다. 아직 기획서가 완벽하게 정해지진 않은거라 다 채우진 못했음!</p>
<p>퀘스트 데이터 테이블에서 <code>bMoveToMaintenance</code> 변수를 제거하고, <code>EventCode</code> 변수를 추가해주었다. 추후에 스크립트 테이블에도 이 Event Code를 넣어줄 것이다.</p>
<pre><code class="language-cpp">// GameState.h

DECLARE_MULTICAST_DELEGATE_OneParam(FOnEventExecute, int32)
FOnEventExecute OnEventExecute;

// GameMode.cpp

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

    TrapperGameState = Cast&lt;ATrapperGameState&gt;(GetWorld()-&gt;GetGameState());
    TrapperGameState-&gt;OnEventExecute.AddUObject(this, &amp;ATrapperGameMode::EventHandle);

    // 생략..
}</code></pre>
<p>게임 스테이트에 델리게이트 변수를 선언해주고, 게임모드에서 이걸 처리해줄 함수를 선언하고 바인딩 해주었다. </p>
<pre><code class="language-cpp">void AQuestManager::QuestComplete()
{
 // 생략..

    // 정비시간으로 이동
    if (GetCurrentQuest().bMoveToMaintenance)
    {
        ATrapperGameMode* GameMode = GetWorld()-&gt;GetAuthGameMode&lt;ATrapperGameMode&gt;();
        GameMode-&gt;SetGameProgress(EGameProgress::Maintenance);
        GameMode-&gt;SetSkipIcon(true);
        GameMode-&gt;InitialItemSetting();

        UE_LOG(LogQuest, Warning, TEXT(&quot;Go Maintenance&quot;));
    }

 // 생략..
 }</code></pre>
<p>이부분의 코드를, 아래처럼 퀘스트를 완료했을 시에 발생시켜야 하는 이벤트 코드를 보내는 쪽으로 바꾸어주었다. </p>
<pre><code class="language-cpp">void AQuestManager::QuestComplete()
{
 // 생략..

    if (GetCurrentQuest().EventCode.Num() != 0)
    {
        ATrapperGameState* GameState = GetWorld()-&gt;GetGameState&lt;ATrapperGameState&gt;();
        for (auto Code : GetCurrentQuest().EventCode)
        {
            // 이벤트 코드 브로드캐스트
            GameState-&gt;OnEventExecute.Broadcast(Code);
        }
    }

 // 생략..
 }</code></pre>
<pre><code class="language-cpp">void ATrapperGameMode::EventHandle(int32 EventCode)
{
    switch (EventCode)
    {

    /// --------------------------------
    ///                Event 
    /// --------------------------------

#pragma region Event
    case 1:
    {
        SetGameProgress(EGameProgress::Maintenance);
        SetSkipIcon(true);
        InitialItemSetting();
    }
    break;
#pragma endregion Event

    /// --------------------------------
    ///                Sequence 
    /// --------------------------------

#pragma region Sequence

#pragma endregion Sequence

    /// --------------------------------
    ///                Dialog 
    /// --------------------------------

#pragma region Dialog

#pragma endregion Dialog

    default: 
        break;
    }
}</code></pre>
<p>이벤트를 재생시킬 함수는 그냥 switch-case 문으로 빠르게 처리했다. 어차피 이벤트가 그렇게 많은 게임이 아니기 때문에, 이정도로 해두는게 가독성도 좋고 유지보수가 쉬울 것이라고 판단했기 때문이다.</p>
<p>이렇게 만들어두고! 내일은 스크립트 매니저와 시퀀스 매니저를 만든 뒤에 본격적으로 메타루프에 살을 붙혀보려고 한다 :)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UE5] 게임 모드 리팩토링 & 웨이브 로직 변경]]></title>
            <link>https://velog.io/@nyong_u_u/DAY-42-%EA%B2%8C%EC%9E%84-%EB%AA%A8%EB%93%9C-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81-%EC%9B%A8%EC%9D%B4%EB%B8%8C-%EB%A1%9C%EC%A7%81-%EB%B3%80%EA%B2%BD</link>
            <guid>https://velog.io/@nyong_u_u/DAY-42-%EA%B2%8C%EC%9E%84-%EB%AA%A8%EB%93%9C-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81-%EC%9B%A8%EC%9D%B4%EB%B8%8C-%EB%A1%9C%EC%A7%81-%EB%B3%80%EA%B2%BD</guid>
            <pubDate>Tue, 30 Jul 2024 09:23:00 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/38f008bb-97fa-4fa1-8f5b-66594143c295/image.png" alt=""></p>
<p>계속 퀘스트 매니저가 모든 것들을 제어하는 식으로 고민해왔는데, <strong>모든 곳에서 게임 흐름을 제어하는 이벤트가 발생</strong>한다는 것을 생각하니, 별도의 관리자가 있는게 훨씬 나을 것 같다는 판단이 섰다. 각각의 매니저들은 각각의 기능만 관리하도록 하고, 한 곳에서 이벤트 코드를 받아 제어하도록 하는게 좋겠다 싶었다. 어차피 게임 흐름은 서버에서 관여할 것이고, 굳이 게임 매니저를 따로 만들지 않고 게임 모드를 사용하는게 가장 적합할 것 같다는 결론이 섰음.</p>
<h2 id="정비-시간--웨이브-상태변수-게임-스테이트로-이동">정비 시간 &amp; 웨이브 상태변수 게임 스테이트로 이동</h2>
<p>우선, 게임모드가 가지고 있던 게임의 상태를 게임 스테이트로 이관하기로 했다. 작업하는 틈틈히 옮겨놓긴 해서 그렇게 많진 않았다.</p>
<h3 id="정비-시간">정비 시간</h3>
<pre><code class="language-cpp">#pragma region Maintenance

// ATrapperGameState.h

// 몇번째 정비시간인지 체크
UPROPERTY(Replicated)
uint8 MaintenanceCount = 0;

UPROPERTY(ReplicatedUsing = OnRep_ChangeMaintenanceTimeLeft)
uint32 MaintenanceTimeLeft;

void AddMaintenanceTime(uint32 Value);

UFUNCTION()
void OnRep_ChangeMaintenanceTimeLeft();

UPROPERTY(ReplicatedUsing = OnRep_ChangeMaintenanceState)
uint32 bMaintenanceInProgress : 1;

UFUNCTION()
void OnRep_ChangeMaintenanceState();

#pragma endregion Maintenance</code></pre>
<p>클라이언트의 UI를 설정하는데 필요한 남은 정비 시간과 정비 시간임을 알 수 있는 변수를 옮겨주고, 게임모드 내에서 사용하고 있는 기존 변수들을 게임 스테이트에서 꺼내쓰도록 바꿔주었다. 또한, 게임모드에서 RPC를 통해 동기화시켜주고 있는 UI를 게임스테이트 내에서 로컬로 처리하도록 변경했다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/f475bffc-c698-4e03-8ed5-bd3f5e6a5af4/image.gif" alt=""></p>
<p>정상적으로 UI가 동기화되는 모습.</p>
<h3 id="웨이브">웨이브</h3>
<pre><code class="language-cpp">#pragma region Wave

public:

    UPROPERTY(ReplicatedUsing = OnRep_ChangeWaveTimeLeft)
    uint32 WaveTimeLeft;

    void SetWaveTime(float Value);

    UFUNCTION()
    void OnRep_ChangeWaveTimeLeft();

    UPROPERTY(Replicated)
    uint32 bWaveInProgress : 1;

    UPROPERTY(Replicated)
    uint32 Wave = 0;

    UPROPERTY(Replicated)
    uint32 SubWave = 1;

    UPROPERTY(Replicated)
    uint32 MaxWave = 20;

#pragma endregion Wave</code></pre>
<p>웨이브도 정비 시간과 마찬가지로 똑같이 처리해주었다.</p>
<h2 id="웨이브-로직-수정">웨이브 로직 수정</h2>
<p>기존 10웨이브가 20웨이브로 늘어났고, 커다란 웨이브 한 묶음이 끝나고 몬스터를 &#39;모두&#39; 잡아야 정비시간으로 넘어가게끔 바뀌었기 때문에 수정해 주어야 하고, 서브 웨이브 단계를 기획분들이 밸런싱하기 쉽게 변경하기로 했다(기존에도 고려하여 설계했지만, 조금 더 이해하기 쉽도록 수정).</p>
<pre><code class="language-cpp">USTRUCT()
struct FWaveInfo : public FTableRowBase
{
    GENERATED_USTRUCT_BODY()

public:
    UPROPERTY(EditAnywhere, BlueprintReadWrite) uint8 bLastSubWave;
    UPROPERTY(EditAnywhere, BlueprintReadWrite) uint8 bLastLargeWave;
    UPROPERTY(EditAnywhere, BlueprintReadWrite) int32 Skeleton;
    UPROPERTY(EditAnywhere, BlueprintReadWrite) int32 Mummy;
    UPROPERTY(EditAnywhere, BlueprintReadWrite) int32 Zombie;
    UPROPERTY(EditAnywhere, BlueprintReadWrite) int32 Debuffer;
    UPROPERTY(EditAnywhere, BlueprintReadWrite) float NextWaveLeftTime;
    UPROPERTY(EditAnywhere, BlueprintReadWrite) FText Memo;
};</code></pre>
<p>기존 웨이브 테이블의 <code>bUseThisWave</code> 변수를 삭제하고, 서브웨이브의 마지막과 웨이브의 한 묶음(1~4웨이브)의 마지막을 알 수 있는 변수들을 추가해주었다.</p>
<pre><code class="language-cpp">void ATrapperGameMode::WaveStart()
{
    UE_LOG(LogQuest, Warning, TEXT(&quot;Wave %d-%d&quot;), TrapperGameState-&gt;Wave, TrapperGameState-&gt;SubWave);

    ALevelScriptActor* LevelScriptActor = GetWorld()-&gt;GetLevelScriptActor();
    ATrapperScriptActor* MyLevelScriptActor = Cast&lt;ATrapperScriptActor&gt;(LevelScriptActor);
    if (MyLevelScriptActor)
    {
        MyLevelScriptActor-&gt;MulticastRPCPlaySystemSound(ESystemSound::WaveStart);
    }

    FWaveInfo CurrentWaveData;
    if (GetWaveData(CurrentWaveData))
    {
        SpawnMonster(CurrentWaveData);
    }
    else
    {
        UE_LOG(LogQuest, Warning, TEXT(&quot;Wave Data Error&quot;));
    }

    TrapperGameState-&gt;bWaveInProgress = true;
    TrapperGameState-&gt;SetWaveTime(CurrentWaveData.NextWaveLeftTime);
    TrapperGameState-&gt;SetWaveInfo();

    if (CurrentWaveData.bLastLargeWave == true)
    {
        TrapperGameState-&gt;SubWave = 1;
        TrapperGameState-&gt;bWaveInProgress = false;
        TrapperGameState-&gt;SetWaveTime(0.f);
        //SetGameProgress(EGameProgress::Maintenance);
        //SetSkipIcon(true);
        return;
    }

    if (CurrentWaveData.bLastSubWave == true)
    {
        // 다음 웨이브로 넘어감
        TrapperGameState-&gt;Wave++;
        TrapperGameState-&gt;SubWave = 1;
        SetGameProgress(EGameProgress::Wave);
        return;
    }

    FTimerHandle WaveTimerHandle;
    GetWorldTimerManager().SetTimer(WaveTimerHandle, FTimerDelegate::CreateLambda([&amp;]
        {
            // 서브 웨이브 계속 진행
            TrapperGameState-&gt;SubWave++;
            WaveStart();
        }
    ), 1.0f, false, CurrentWaveData.NextWaveLeftTime);
}</code></pre>
<p>초반부분은 기존 코드와 거의 동일하다. 만약 이전에 진행한 웨이브가 Large Wave 였다면 더이상 웨이브를 진행하지 않고 리턴하도록 했다. 마지막 Sub Wave일 경우 Wave를 증가시키고 Sub Wave를 1로 초기화한다. 모두 아닌 경우 타이머를 설정하고 Sub Wave를 증가시키고 재귀하도록 했다. 확실히 이전 로직보다는 깔끔한 느낌이다.</p>
<pre><code class="language-cpp">void ATrapperGameMode::GetThisWaveRemainingMonsterCount()
{
    bool ThisLargeWaveEnd = false;

    while(!ThisLargeWaveEnd)
    {
        for (uint32 k = 1; k &lt; 6; k++)
        {
            FString WaveText = TEXT(&quot;Wave&quot;) + FString::FromInt(RemainingMonsterCountWave) + TEXT(&quot;_&quot;) + FString::FromInt(k);
            FWaveInfo* Data = WaveData-&gt;FindRow&lt;FWaveInfo&gt;(*WaveText, FString());
            if (!Data) continue;

            uint32 TotalMonster = 0;

            TotalMonster += Data-&gt;Skeleton;
            TotalMonster += Data-&gt;Mummy;
            TotalMonster += Data-&gt;Zombie;
            TotalMonster += Data-&gt;Debuffer;
            TotalMonster += Data-&gt;Boss;

            TrapperGameState-&gt;ChangeRemainingMonsterCount(TotalMonster);
            //UE_LOG(LogQuest, Warning, TEXT(&quot;This Wave %d-%d, Total Spawn Monster %d&quot;), RemainingMonsterCountWave, k, TotalMonster);

            if (Data-&gt;bLastLargeWave)
            {
                ThisLargeWaveEnd = true;
            }
        }

        RemainingMonsterCountWave++;
    }
}</code></pre>
<p>개인적으로는 데이터 구성을 바꿈으로써 변경된 이 함수가 마음에 들었다. 기존에는 다섯개 웨이브 단위로 하드코딩 되어있어서 혹시라도 웨이브 단위가 바뀌면 변경해주어야 했는데, Large Wave가 끝나면 계산을 끝내도록 해주었기 때문에 웨이브 단위가 모두 달라져도 상관이 없어졌다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/58d64ddf-2a2d-448d-90b5-988118700a2c/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/c7f36f73-eab4-42c5-b9b1-8d6704082dd4/image.png" alt=""></p>
<p>정상적으로 진행되고 있는 웨이브 테스트 스크린샷 :)</p>
<h2 id="몬스터가-모두-잡히면-정비-시간으로-넘어가도록-하기">몬스터가 모두 잡히면 정비 시간으로 넘어가도록 하기</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/59b265bf-d016-4e5a-a3b5-3456fceaa1e2/image.png" alt=""></p>
<p>정비 단계와 웨이브 단계 모두 퀘스트가 있다. 정비단계 시간이 끝나거나 스킵했을 때 / 모든 몬스터 처치가 끝나면 퀘스트 매니저로 각각의 완료 퀘스트 코드를 넘긴 뒤, 퀘스트 매니저에서 다시 이벤트 코드를 게임모드로 보내는 식으로 변경해주려고 한다.</p>
<h3 id="정비단계-퀘스트-완료">정비단계 퀘스트 완료</h3>
<pre><code class="language-cpp">void ATrapperGameMode::MaintenanceStart()
{
    // 정비 시간 설정
    TrapperGameState-&gt;SetMaintenanceTime(MaintenanceTime);
    TrapperGameState-&gt;bMaintenanceInProgress = true;
    TrapperGameState-&gt;MaintenanceCount++;
    TrapperGameState-&gt;OnRep_ChangeMaintenanceState();

    // 웨이브에 출현할 몬스터 계산
    GetThisWaveRemainingMonsterCount();

    FTimerHandle MaintenanceTimerHandle;
    GetWorldTimerManager().SetTimer(MaintenanceTimerHandle, FTimerDelegate::CreateLambda([&amp;]
        {
            // 정비시간을 스킵했을 경우 리턴
            if (bSkipMaintenance) return;

            SetSkipIcon(false);

            TrapperGameState-&gt;OnQuestExecute.Broadcast(99, true);

            // 다음 웨이브 시작
            TrapperGameState-&gt;Wave++;
            TrapperGameState-&gt;bMaintenanceInProgress = false;
            SetGameProgress(EGameProgress::Wave);
        }
    ), 1.0f, false, MaintenanceTime);
}</code></pre>
<p><code>TrapperGameState-&gt;OnQuestExecute.Broadcast(99, true);</code> 델리게이트에 99번 완료 코드를 보내주면, 정비 퀘스트가 완료된다.</p>
<pre><code class="language-cpp">void ATrapperGameMode::SkipMaintenance()
{
    bSkipMaintenance = true;
    TrapperGameState-&gt;bMaintenanceInProgress = false;

    TrapperGameState-&gt;OnQuestExecute.Broadcast(99, true);
    SetSkipIcon(false);

    TrapperGameState-&gt;Wave++;
    SetGameProgress(EGameProgress::Wave);
}</code></pre>
<p><code>SkipMaintenance()</code> 함수에서도 호출하도록 넣어주었다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/c12366b7-69db-49e8-9f06-e667179567d3/image.gif" alt=""></p>
<h3 id="웨이브-퀘스트-완료">웨이브 퀘스트 완료</h3>
<p>게임 스테이트에 있는 <code>CurrentMonsterCount(현재 스폰된 몬스터 수)</code> ,  <code>RemainingMonsterCount(Large Wave동안 스폰될 남은 몬스터 수)</code> 수가 모두 0이 되어야 정비 시간으로 넘어간다.</p>
<pre><code class="language-cpp">void ATrapperGameState::ChangeMonsterCount(int32 Count)
{
    if (HasAuthority())
    {
        CurrentMonsterCount += Count;
        OnRep_ChangeCurrentMonster();

        if (CurrentMonsterCount == 0)
        {
            CheckAllWaveMonsterDie();
        }
    }
}

void ATrapperGameState::CheckAllWaveMonsterDie()
{
    if (CurrentMonsterCount == 0 &amp;&amp; RemainingMonsterCount == 0)
    {
        OnQuestExecute.Broadcast(98, true);
    }
}</code></pre>
<p>굳이 Tick에서 계속 검사할 필요 없이 남아있는 몬스터 수가 0이 될때마다 두 변수를 모두 체크해서 이벤트를 발생시켜주면 될 것 같으므로, 이렇게 구현해보았다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/3f9f20d6-6e5b-46ed-97c3-22b98a9d6318/image.gif" alt=""></p>
<p>아직 Skip UI는 정상적으로 뜨지 않지만, 모든 몬스터가 죽었을 때 퀘스트가 정상적으로 완료되고 정비 단계로 이동하는 것을 볼 수 있다.</p>
<p>퀘스트 완료 함수에 스킵 UI를 띄워주는 코드를 임시로 넣어주었다. 이제 커밋해두고, 게임모드에 이벤트 코드를 받아 처리하는 함수를 만들어 줄 것이다. 그럼 퀘스트 완료 시에 이벤트 코드를 게임모드쪽으로 전송하고, 이벤트 코드에 따라 게임모드는 게임의 진행도를 관리해주면 된다. <del>(이론은 완벽해..)</del></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UE5] 퀘스트 시스템 개편 - 2 ]]></title>
            <link>https://velog.io/@nyong_u_u/DAY-38-%ED%80%98%EC%8A%A4%ED%8A%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B0%9C%ED%8E%B8-2</link>
            <guid>https://velog.io/@nyong_u_u/DAY-38-%ED%80%98%EC%8A%A4%ED%8A%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B0%9C%ED%8E%B8-2</guid>
            <pubDate>Tue, 30 Jul 2024 02:26:09 GMT</pubDate>
            <description><![CDATA[<h1 id="서브시스템-포기---액터로-교체">서브시스템 포기 - 액터로 교체</h1>
<p>흐어어엉엉,, 역시는 역시 한번에 잘 풀릴리가 없다.. 네트워크 지원이야 안된다는걸 알고 사용한거긴 하지만 따로 액터를 생성해서 RPC를 날려서 서브시스템의 함수를 호출해도 클라이언트는 받질 못하고.. 동기화도 안되고.............. 잘 모르고 편해보인다고 쓰면 이렇게 되는거다.... 반나절을 넘게 헤맸지만 아직도 왜 안되는건지 잘 모르겠다.. 다시 액터로 교체 들어갑니다,,, 그냥 레벨에 배치해서 써야겠다 ^-^..ㅎ..</p>
<p>(+ 교수님께 여쭤봤더니 서브시스템은 동기화가 안되는거라고 한다.. 서버 관련된건 여기서 안 쓰는게 맞다함)
(+ 뒤늦게 생각해봤는데.. 만들어준 액터에 Owner 설정을 안해줬던 것이 문제였던 것 같다..)</p>
<h1 id="퀘스트-매니저-리뷰">퀘스트 매니저 리뷰</h1>
<p>갑자기 왜 리뷰로 넘어왔냐면(..) 퀘스트 매니저를 액터로 바꾸면서 정신없이 구현하는 바람에... 과정을 설명하기가 애매해져버렸다. 우선 지금까지의 진행상황이라도 정리하기로 했다.</p>
<h2 id="생성자--beginplay">생성자 &amp; BeginPlay</h2>
<pre><code class="language-cpp">AQuestManager::AQuestManager()
{
    // Set this actor to call Tick() every frame.  You can turn this off to improve performance if you don&#39;t need it.
    PrimaryActorTick.bCanEverTick = true;

    static ConstructorHelpers::FObjectFinder&lt;UDataTable&gt; QuestTable(TEXT(&quot;/Script/Engine.DataTable&#39;/Game/Blueprints/Data/DT_QuestData.DT_QuestData&#39;&quot;));
    if (QuestTable.Succeeded() &amp;&amp; QuestTable.Object)
    {
        QuestData = QuestTable.Object;
    }

    static ConstructorHelpers::FObjectFinder&lt;UDataTable&gt; QuestActorTable(TEXT(&quot;/Script/Engine.DataTable&#39;/Game/Blueprints/Data/DT_QuestActorData.DT_QuestActorData&#39;&quot;));
    if (QuestActorTable.Succeeded() &amp;&amp; QuestActorTable.Object)
    {
        QuestActorData = QuestActorTable.Object;
    }

    static ConstructorHelpers::FObjectFinder&lt;UDataTable&gt; TutorialMonsterTable(TEXT(&quot;/Script/Engine.DataTable&#39;/Game/Blueprints/Data/DT_TutorialMonster.DT_TutorialMonster&#39;&quot;));
    if (TutorialMonsterTable.Succeeded() &amp;&amp; TutorialMonsterTable.Object)
    {
        TutorialMonsterData = TutorialMonsterTable.Object;
    }
}</code></pre>
<p>생성자에서는 데이터 테이블을 읽어들인다. 튜토리얼의 몬스터 배치때문에 튜토리얼 몬스터 데이터 테이블(배치해야하는 몬스터의 Position / Rotation 값)을 추가해주었다.</p>
<pre><code class="language-cpp">void ATrapperPlayerController::BeginPlay()
{
    Super::BeginPlay();

    UEnhancedInputLocalPlayerSubsystem* SubSystem =
        ULocalPlayer::GetSubsystem&lt;UEnhancedInputLocalPlayerSubsystem&gt;(GetLocalPlayer());
    if (SubSystem &amp;&amp; DefaultIMC)
    {
        SubSystem-&gt;AddMappingContext(DefaultIMC, 0);
    }

    if (!HasAuthority())
    {
        ServerRPCUpdatebIsClientStart(true);
    }

    InitializeHUD();

    for (AQuestManager* QuestManager : TActorRange&lt;AQuestManager&gt;(GetWorld()))
    {
        QuestManager-&gt;SetOwner(this);
        QuestManager-&gt;SetQuestUI();
    }
}</code></pre>
<p>플레이어 컨트롤러의 BeginPlay에서 퀘스트 매니저의 Owner를 자신으로 설정해주고, SetUI를 호출해준다. 왜 여기서 UI를 세팅하도록 호출해주었냐면, <code>SetUI</code> 함수 내에서 플레이어 컨트롤러를 호출해 UI를 설정해주고 있기 때문이다. 추후 시퀀스 매니저가 생기면, 오프닝 시퀀스 재생이 끝난 후 거기서 호출해줄 생각이다.</p>
<pre><code class="language-cpp">void AQuestManager::BeginPlay()
{
    Super::BeginPlay();

    ATrapperGameState* GameState = GetWorld()-&gt;GetGameState&lt;ATrapperGameState&gt;();
    GameState-&gt;OnQuestExecute.AddUObject(this, &amp;AQuestManager::QuestCheck);

    if (QuestData)
    {
        // 리스트에 퀘스트 추가
        AddQuest();
    }

    if (QuestActorData)
    {
        // 퀘스트 액터 생성
        CreateQuestActor();
    }

    // 튜토리얼 몬스터 세팅
    if (HasAuthority())
    {
        FTimerHandle Handle;
        GetWorldTimerManager().SetTimer(Handle, this, &amp;AQuestManager::TutorialMonsterSetting, 1.f, false);
    }

    // 이펙트 생성
    QuestEffect = GetWorld()-&gt;SpawnActor&lt;AQuestEffect&gt;();
    SetQuestEffect();
}</code></pre>
<p>퀘스트 매니저의 <code>BeginPlay</code> 함수가 호출되면, 퀘스트 리스트에 퀘스트를 추가하고, 현재 퀘스트에 관련된 액터를 생성해준다. </p>
<p>튜토리얼 몬스터 세팅의 경우 몬스터를 만든 친구의 로직(Begin Play가 끝난 이후에 호출되어야 함)때문에 일단 임시로 타이머를 호출해놨다.</p>
<p>퀘스트를 추가하는 부분을 제외한 나머지도 마찬가지로 오프닝 시퀀스가 끝난 후 설정하도록 변경할 것이다.</p>
<h2 id="addquest">AddQuest()</h2>
<pre><code class="language-cpp">void AQuestManager::AddQuest()
{
    LastQuestIndex = QuestData-&gt;GetRowMap().Num() - 1;

    for (int i = 1; i &lt;= QuestData-&gt;GetRowMap().Num(); i++)
    {
        FQuestInfo* Data = QuestData-&gt;FindRow&lt;FQuestInfo&gt;(*FString::FromInt(i), FString());

        FQuest Quest;
        Quest.Initialize(Data-&gt;QuestCode, Data-&gt;Title, Data-&gt;Description,
            Data-&gt;GoalCount1P, Data-&gt;GoalCount2P, Data-&gt;EffectPosition, Data-&gt;QuestActorCode);

        if (Data-&gt;bIsAlwaysChecking)
        {
            Quest.bIsAlwaysChecking = true;
        }

        if (Data-&gt;bChangeMainQuest)
        {
            Quest.bChangeMainQuest = true;
        }

        if (Data-&gt;bMoveToMaintenance)
        {
            Quest.bMoveToMaintenance = true;
        }

        if (Data-&gt;ExceptionCode != 0)
        {
            Quest.ExceptionCode = Data-&gt;ExceptionCode;
        }

        if (Data-&gt;bTutorialEnd)
        {
            TutorialEndIndex = i;
        }

        QuestList.Add(Quest);
    }
}</code></pre>
<p>퀘스트 추가 함수에 예외문이 많이 생겼다. 항상 체크해야하는 퀘스트를 체크하기 위한 불변수, 메인 퀘스트가 바뀌는 경우를 체크하기 위한 불변수(추후에 메인 애니메이션마다 UI 애니메이션을 넣어주어야 하기 때문), 정비시간으로 이동해야하는 경우를 체크하기 위한 불변수, 추가적으로 예외처리를 해줘야 하는 상황을 위한 예외코드, 튜토리얼 스킵에 사용해야 해서 필요한 튜토리얼의 인덱스.. 아마 추가적으로 기획자분들이 얘기하는 것들에 따라 더 생길수도 있는데, 일단은 이정도로 마무리해두었다.</p>
<h2 id="questcheck">QuestCheck()</h2>
<pre><code class="language-cpp">void AQuestManager::QuestCheck(int32 InQuestCode, bool bIs1P)
{
    // 항상 체크하는 퀘스트 확인
    AlwaysCheckQuestCheck(InQuestCode, bIs1P);

    FQuest&amp; CurrentQuest = GetCurrentQuest();

    // 퀘스트 코드 안맞으면 Exit
    if (CurrentQuest.QuestCode != InQuestCode)
    {
        return;
    }

    // Count 증가
    if (bIs1P)
    {
        CurrentQuest.Count1P++;
    }
    else
    {
        CurrentQuest.Count2P++;
    }

    if (IsQuestClear())
    {
        UE_LOG(LogQuest, Warning, TEXT(&quot;-- Complete --&quot;));

        // 퀘스트 완료 처리
        if (HasAuthority())
        {
            QuestComplete();
        }
        else
        {
            ServerRPCQuestComplete();
        }
    }
    else
    {
        UE_LOG(LogQuest, Warning, TEXT(&quot;-- Keep Going --&quot;));

        if (HasAuthority())
        {
            ClientRPCAddCount(CurrentQuestIndex, CurrentQuest.Count1P);
        }
        else
        {
            ServerRPCAddCount(CurrentQuestIndex, CurrentQuest.Count2P);
        }
    }

    // 이펙트 설정
    if (HasAuthority())
    {
        if (GetCurrentQuest().Count1P &gt;= GetCurrentQuest().GoalCount1P)
        {
            QuestEffect-&gt;QuestPingEffect-&gt;Deactivate();
        }
    }
    else
    {
        if (GetCurrentQuest().Count2P &gt;= GetCurrentQuest().GoalCount2P)
        {
            QuestEffect-&gt;QuestPingEffect-&gt;Deactivate();
        }
    }

    // UI 변경
    SetQuestUI();

    UE_LOG(LogQuest, Warning, TEXT(&quot;[%s] Count 1P : %d / 2P : %d - Current Index %d&quot;),
        *GetCurrentQuest().Title, GetCurrentQuest().Count1P, GetCurrentQuest().Count2P, CurrentQuestIndex);
}</code></pre>
<p>게임 스테이트의 델리게이트에 바인딩되어있는 함수이다. 클라이언트와 서버 모두 호출되고 있고, 들어오는 퀘스트 코드에 따라 진행도만 증가시키거나 퀘스트 완료를 판정해준다. </p>
<pre><code class="language-cpp">void AQuestManager::AlwaysCheckQuestCheck(int32 InQuestCode, bool bIs1P)
{
    for (int i = 0; i &lt; QuestList.Num(); i++)
    {
        checkf(QuestList.IsValidIndex(i), TEXT(&quot;Always Check List Index Error&quot;));
        FQuest&amp; AlwaysCheckQuest = QuestList[i];

        if (!AlwaysCheckQuest.bIsAlwaysChecking ||
            AlwaysCheckQuest.QuestCode != InQuestCode ||
            i == CurrentQuestIndex)
        {
            continue;
        }

        //---------------------------------------------------------------

        // Count 증가
        if (bIs1P)
        {
            AlwaysCheckQuest.Count1P++;
            ClientRPCAddCount(i, AlwaysCheckQuest.Count1P);
        }
        else if (!bIs1P)
        {
            AlwaysCheckQuest.Count2P++;
            ServerRPCAddCount(i, AlwaysCheckQuest.Count2P);
        }

        UE_LOG(LogQuest, Warning, TEXT(&quot;-- Always Check Quest -- Count 1P : %d / 2P : %d&quot;),
            AlwaysCheckQuest.Count1P, AlwaysCheckQuest.Count2P);
    }
}</code></pre>
<p>항상 체크해야 하는 퀘스트를 확인하는 함수. 조건문 처리를 해 해당되지 않는 퀘스트들은 바로바로 continue 시키고, 맞다면 진행도를 증가시킨다.</p>
<pre><code class="language-cpp">void AQuestManager::ServerRPCQuestComplete_Implementation()
{
    UE_LOG(LogQuest, Warning, TEXT(&quot;Server RPC Quest Complete&quot;));

    QuestComplete();
}

void AQuestManager::ServerRPCAddCount_Implementation(int32 Index, int32 Count)
{
    QuestList[Index].Count2P = Count;
    SetQuestUI();
}

void AQuestManager::ClientRPCAddCount_Implementation(int32 Index, int32 Count)
{
    QuestList[Index].Count1P = Count;
    SetQuestUI();
}</code></pre>
<p><code>AddCount()</code> 함수는 상대방의 카운트를 받아 동기화해주는 작업을 한다.</p>
<h2 id="questcomplete">QuestComplete()</h2>
<pre><code class="language-cpp">void AQuestManager::QuestComplete()
{
    UE_LOG(LogQuest, Warning, TEXT(&quot;[%s] Quest Complete&quot;), *GetCurrentQuest().Title);

    PlayQuestCompleteSound();

    // 정비시간으로 이동
    if (GetCurrentQuest().bMoveToMaintenance)
    {
        ATrapperGameMode* GameMode = GetWorld()-&gt;GetAuthGameMode&lt;ATrapperGameMode&gt;();
        GameMode-&gt;SetGameProgress(EGameProgress::Maintenance);
        GameMode-&gt;InitialItemSetting();

        UE_LOG(LogQuest, Warning, TEXT(&quot;Go Maintenance&quot;));
    }

    // 마지막 퀘스트 클리어일 경우, 게임 클리어 판정
    if (CurrentQuestIndex == LastQuestIndex)
    {
        //MyOwner-&gt;SetGameProgress(EGameProgress::GameClear);
        UE_LOG(LogQuest, Warning, TEXT(&quot;Go GameClear&quot;));
        return;
    }

    CurrentQuestIndex++;

    // 이미 클리어 했을 시 2초 뒤에 QuestComplete 호출
    if (IsQuestClear())
    {
        FTimerHandle TimerHandle;
        GetWorld()-&gt;GetTimerManager().SetTimer(TimerHandle, FTimerDelegate::CreateLambda([&amp;]
            {
                GetCurrentQuest().bIsAlwaysChecking = false;
                QuestComplete();
            }
        ), 1.0f, false, 2.0f);
    }

    // 현재 퀘스트 액터 정리
    DestroyQuestActor();

    // 다음 퀘스트 액터 준비
    CreateQuestActor();

    // 둘다 서버만 변경함
    // 클라이언트 이펙트는 CurrentIndex의 OnRep 함수에서 변경
    SetQuestUI();
    SetQuestEffect();
}

void AQuestManager::OnRep_ChangeCurrentIndex()
{
    SetQuestEffect();
    SetQuestUI();
}</code></pre>
<p>퀘스트가 완료되었을 때 서버에서 호출하는 함수. 퀘스트 완료음을 재생하고, 조건에 따라 게임 진행 상태를 진척시킨다. <code>CurrentQuestIndex</code> 를 리플리케이트 설정하여 인덱스가 바뀌었을 경우 클라이언트쪽에서 UI와 이펙트를 설정하도록 해주었다.</p>
<pre><code class="language-cpp">void AQuestManager::HandleQuestExceptions()
{
    switch (GetCurrentQuest().ExceptionCode)
    {
        // 찰코함정 활성화
        case 1 :
        {
            for (ABearTrap* BearTrap : TActorRange&lt;ABearTrap&gt;(GetWorld()))
            {
                BearTrap-&gt;MagneticTriggerControl(true);
            }
            break;
        }
        default:
        {
            break;
        }
    }

}</code></pre>
<p><code>CreateQuestActor()</code> 함수 마지막에 넣어준 예외처리 함수이다. 퀘스트 데이터를 읽어올 때 받아온 예외 코드를 스위치문으로 만들어 필요한 처리를 해줄 수 있도록 만들어두었다.</p>
<h2 id="skiptutorial">SkipTutorial()</h2>
<pre><code class="language-cpp">void AQuestManager::SkipTutorial()
{
    CurrentQuestIndex = TutorialEndIndex;

    SetQuestUI();
    SetQuestEffect();

    DestroyQuestActor();

    for (ATutorialMonster* TutorialMonster : TActorRange&lt;ATutorialMonster&gt;(GetWorld()))
    {
        TutorialMonster-&gt;Teleport(TutorialMonster-&gt;StartPoint);
    }

    for (ABearTrap* BearTrap : TActorRange&lt;ABearTrap&gt;(GetWorld()))
    {
        BearTrap-&gt;DestroyHandle();
    }

    CreateQuestActor();
}</code></pre>
<p>튜토리얼 스킵 함수를 호출했을 때, 현재 퀘스트 인덱스를 튜토리얼의 다음 퀘스트로 바꾸어준 뒤, 튜토리얼 퀘스트와 관련된 액터들을 정리해준다.</p>
<h2 id="set-effect--ui">Set Effect &amp; UI</h2>
<pre><code class="language-cpp">void AQuestManager::SetQuestEffect()
{
    if (QuestEffect)
    {
        bool IsActive = false;
        if (GetCurrentQuest().EffectPosition != FVector::Zero())
        {
            IsActive = true;
        }

        QuestEffect-&gt;SetQuestEffect(IsActive, GetCurrentQuest().EffectPosition);
    }
}

void AQuestManager::SetQuestUI()
{
    FQuest CurrentQuest = GetCurrentQuest();
    FString Description = CurrentQuest.Description;

    if (CurrentQuest.TotalGoalCount &gt; 1)
    {
        uint8 TotalGoal = CurrentQuest.Count1P + CurrentQuest.Count2P;
        Description += TEXT(&quot;(&quot;) + FString::FromInt(TotalGoal) + TEXT(&quot; / &quot;) +
            FString::FromInt(CurrentQuest.TotalGoalCount) + TEXT(&quot;)&quot;);
    }

    for (ATrapperPlayerController* PlayerController : TActorRange&lt;ATrapperPlayerController&gt;(GetWorld()))
    {
        PlayerController-&gt;SetQuestInfo(CurrentQuest.Title, Description);
    }
}</code></pre>
<p>이펙트와 UI를 설정해주는 코드이다. 기존의 코드를 거의 그대로 사용한다 :)</p>
<hr>
<p>이제 둘이서 클리어해야 하는 퀘스트더라도, 각각 완료된 상황을 파악해 이펙트를 꺼주거나 하는 등의 처리가 가능해졌다. 예외처리도 할 수 있게 되었고, 네트워크를 통하지 않고 UI와 이펙트를 로컬로 처리하게 되었다(가장 큰 목표..). 얼마나 효율적으로 코드를 개선했는지는 확실치 않지만, 확장에는 조금 더 유연한 구조가 되었길 기도해본다.</p>
<p>이제 시퀀스 매니저와 스크립트 매니저를 만들고, 게임 모드 - 게임 스테이트 - 퀘스트 매니저 - 시퀀스 매니저 - 스크립트 매니저간에 서로 얽히어 있는 이벤트들을 처리하며 메타루프를 완성해나가기로 했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UE5] 퀘스트 시스템 개편 - 1]]></title>
            <link>https://velog.io/@nyong_u_u/DAY-36-37-%ED%80%98%EC%8A%A4%ED%8A%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B0%9C%ED%8E%B8</link>
            <guid>https://velog.io/@nyong_u_u/DAY-36-37-%ED%80%98%EC%8A%A4%ED%8A%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B0%9C%ED%8E%B8</guid>
            <pubDate>Wed, 24 Jul 2024 12:52:14 GMT</pubDate>
            <description><![CDATA[<p>이제 코어루프가 끝났으니, 기획의 본 의도에 맞게 게임의 스케일을 키워야 한다. 근데 도대체 왜 구조는 고심해서 짜고나서도 돌아보면 마음에 안드는건지..</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/f96e7e04-254f-459c-93c6-5cf6bd509543/image.png" alt=""></p>
<p>지금 게임의 진행 단계 &amp; 퀘스트 &amp; 스크립트 &amp; 시네마틱이 맞물려 돌아가고 있어서 너무 서로에 대한 의존도가 높다. 이걸 어떻게 관리해야 할까..</p>
<p>게임의 단계, 시네마틱 영상, 스크립트, 카메라 무빙 등 퀘스트의 진행 단계에 따라 트리거되는 것들이 많다. 따라서, 가장 먼저 퀘스트 시스템을 개편하려고 한다.</p>
<p>지금은 퀘스트 매니저가 게임모드에서 생성되고 있는데, 서브시스템으로 만들어 퀘스트를 관리하도록 해보려고 한다. 이 친구는 각 퀘스트에 맞게끔 액터를 생성해주는 역할도 할 것이고, 시네마틱이나 카메라 연출을 재생시켜주기도 할 것이다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/30822841-f6fe-418c-925b-98e8b98a5eff/image.png" alt=""></p>
<p>코드를 짜봐야 알겠지만, 대충 이런 느낌의 구조가 나올 것 같다. 시네마틱과 스크립트, 카메라 이벤트들은 퀘스트 매니저와 게임 모드의 명령을 받고 재생만 하고, 게임 모드와 퀘스트 매니저는 서로의 함수를 호출해야 할 듯.</p>
<h2 id="퀘스트-서브시스템-생성">퀘스트 서브시스템 생성</h2>
<p>먼저, 레벨과 같은 생명주기로 돌아가는 <code>UWorldSubsystem</code> 을 상속받아 <code>QuestSubSystem</code> 클래스를 만들어주었다.</p>
<pre><code class="language-cpp">DECLARE_MULTICAST_DELEGATE_OneParam(FOnQuestExecute, int32)

UCLASS()
class TRAPPERPROJECT_API UQuestSubsystem : public UWorldSubsystem
{
    GENERATED_BODY()

public:
    UQuestSubsystem();

public:
    virtual void OnWorldBeginPlay(UWorld&amp; InWorld) override;

    TObjectPtr&lt;class UDataTable&gt; QuestData;
    void AddQuest();

    TArray&lt;FQuest&gt; QuestList;
    int32 CurrentQuestIndex = 0;
    FQuest GetCurrentQuest();

    FOnQuestExecute OnQuestExecute;
    void QuestCheck(int32 InQuestCode);
    void QuestComplete();

    TArray&lt;TObjectPtr&lt;class AInteract&gt;&gt; QuestActorBox;
    void ActiveQuestActor(int32 QuestID);

    TObjectPtr&lt;class AQuestEffect&gt; QuestEffect;
    void SetQuestUI(FString Title, FString Contents);
};</code></pre>
<p>기존 게임모드, 퀘스트 매니저에서 관리하던 퀘스트와 관련된 변수와 함수의 선언부를 옮겨주었다. 구현부는 변경해야 하므로, 조금 이따가 이어서 진행하도록 하겠다!</p>
<h2 id="퀘스트-구조체-개선">퀘스트 구조체 개선</h2>
<p>기존의 퀘스트는 서버가 모두 관리했고, 클라이언트는 퀘스트의 정보를 갖고있지 않은채로 서버에서 데이터를 받아 UI만 갱신하는 식으로 관리했었다. 따라서 한명이 퀘스트를 완료해도 누가 완료했는지 알 수 없는 문제가 있었다. 클라이언트쪽에서 퀘스트를 완료했다면 클라이언트쪽에서 UI나 이펙트 등으로 표시해주기로 했는데, 그러기 위해서는 먼저 퀘스트 구조체를 변경해야 한다. </p>
<p>겸사겸사 아이템 획득처럼 현재 퀘스트가 진행중이 아니더라도 체크해야 하는 것들도 하드코딩 해놨던 것들을 자동화 시켜보기로 했다. 또한, 퀘스트를 진행할 때 퀘스트와 관련된 액터들을 미리 배치해두는 것이 아니라 필요할 때마다 생성하도록 바꾸기 위해 퀘스트 액터 데이터 테이블을 만들고, 그 데이터 테이블을 참조하여 액터를 생성하도록 퀘스트 구조체가 생성해야 할 데이터 테이블의 번호를 갖고있도록 바꿔줄 것이다.</p>
<pre><code class="language-cpp">// 변경한 퀘스트 구조체

USTRUCT()
struct FQuest
{
    GENERATED_USTRUCT_BODY()

public:
    FQuest() {}
    void Initialize(EQuestType InQuestType, FString InTitle, FString InDescription, int32 InQuestCode, int32 InGoalCount, FVector InPingPosition);

    // 퀘스트 종류
    EQuestType QuestType;

    // 퀘스트 코드
    int32 QuestCode;

    // 제목 &amp; 설명
    FString Title;
    FString Description;

    // 해야하는 행동의 횟수
    int32 Count = 0;
    int32 GoalCount = 0;

    // 퀘스트를 진행중이 아닐때도 체크해야할 때
    uint8 bIsAlwaysChecking : 1 = false;

    // 퀘스트 완료 확인
    uint8 bUserComplete : 1 = false;
    uint8 bTeamComplete : 1 = false;

    // 활성화해야 할 액터 코드
    TArray&lt;int32&gt; QuestActorCode;

    // 퀘스트 이펙트 위치
    FVector EffectPosition = FVector();
};</code></pre>
<pre><code class="language-cpp">// 데이터 테이블용 구조체

USTRUCT()
struct FQuestInfo : public FTableRowBase
{
    GENERATED_USTRUCT_BODY()

public:
    UPROPERTY(EditAnywhere, BlueprintReadWrite) EQuestType QuestType;

    UPROPERTY(EditAnywhere, BlueprintReadWrite) FString Title;
    UPROPERTY(EditAnywhere, BlueprintReadWrite) FString Description;

    UPROPERTY(EditAnywhere, BlueprintReadWrite) int32 GoalCount;
    UPROPERTY(EditAnywhere, BlueprintReadWrite) uint8 bIsAlwaysChecking : 1;

    UPROPERTY(EditAnywhere, BlueprintReadWrite) TArray&lt;int32&gt; QuestActorCode;
    UPROPERTY(EditAnywhere, BlueprintReadWrite) FVector EffectPosition;

    UPROPERTY(EditAnywhere, BlueprintReadWrite) FString Memo;
};</code></pre>
<h2 id="퀘스트-액터-데이터-테이블-생성">퀘스트 액터 데이터 테이블 생성</h2>
<pre><code class="language-cpp">USTRUCT()
struct FQuestActorInfo : public FTableRowBase
{
    GENERATED_USTRUCT_BODY()

public:
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    TSubclassOf&lt;class AActor&gt; QuestActor;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    FVector Position;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    FRotator Rotation;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    FVector Scale;
};</code></pre>
<p>생성할 액터를 넣어두는 테이블을 만들어 준 뒤, 기존 퀘스트에 필요했던 액터들을 테이블에 데이터로 옮겨주었다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/810e30a3-89d6-4e8a-b122-1483f3b7d428/image.png" alt=""></p>
<h2 id="퀘스트에-해당하는-액터-생성">퀘스트에 해당하는 액터 생성</h2>
<pre><code class="language-cpp">void UQuestSubsystem::Initialize(FSubsystemCollectionBase&amp; Collection)
{
    Super::Initialize(Collection);

    FString QuestTablePath = TEXT(&quot;/Script/Engine.DataTable&#39;/Game/Blueprints/Data/DT_QuestData.DT_QuestData&#39;&quot;);
    UDataTable* QuestTable = LoadObject&lt;UDataTable&gt;(nullptr, *QuestTablePath);
    if (QuestTable)
    {
        QuestData = QuestTable;
    }

    UDataTable* QuestActorTable = LoadObject&lt;UDataTable&gt;(nullptr, TEXT(&quot;/Script/Engine.DataTable&#39;/Game/Blueprints/Data/DT_QuestActorData.DT_QuestActorData&#39;&quot;));
    if (QuestActorTable)
    {
        QuestActorData = QuestActorTable;
    }
}</code></pre>
<p>서브시스템의 <code>Initialize</code> 함수를 오버라이드 한 뒤, 퀘스트 테이블과 퀘스트 액터 테이블을 받아왔다.</p>
<pre><code class="language-cpp">void UQuestSubsystem::OnWorldBeginPlay(UWorld&amp; InWorld)
{
    OnQuestExecute.AddUObject(this, &amp;UQuestSubsystem::QuestCheck);

    if (QuestData)
    {
        // 리스트에 퀘스트 추가
        AddQuest();
    }

    // 테스트용 -----------------------------------
    if (QuestActorData)
    {
        // 퀘스트 액터 생성
        CreateQuestActor();
    }

}</code></pre>
<pre><code class="language-cpp">void UQuestSubsystem::AddQuest()
{
    for (int i = 1; i &lt;= QuestData-&gt;GetRowMap().Num(); i++)
    {
        FQuestInfo* Data = QuestData-&gt;FindRow&lt;FQuestInfo&gt;(*FString::FromInt(i), FString());

        FQuest Quest;
        Quest.Initialize(Data-&gt;QuestType, Data-&gt;Title, Data-&gt;Description, Data-&gt;QuestActorCode, Data-&gt;GoalCount, Data-&gt;EffectPosition);
        QuestList.Add(Quest);

        if (Data-&gt;bIsAlwaysChecking)
        {
            AlwaysCheckQuestList.Add(&amp;QuestList.Last());
        }
    }
}</code></pre>
<p><code>AddQuest</code> 함수는 바뀐 퀘스트의 구조체에 맞게 초기화하도록 바꿔주었고, <code>bIsAlwaysChecking</code> 변수가 true일 경우 <code>AlwaysCheckQuestList</code> 에 추가로 저장해주었다. <code>AlwaysCheckQuestList</code> 는, <code>QuestList</code> 안에 들어가 있는 퀘스트 구조체의 주소를 저장하는 배열이다.</p>
<pre><code class="language-cpp">void UQuestSubsystem::CreateQuestActor()
{
    // 서버가 아니면 return
    ENetMode NetMode = GetWorld()-&gt;GetNetMode();
    if (NetMode != NM_ListenServer)
    {
        return;
    }

    // 현재 액터에서 생성해야 할 액터 코드
    TArray&lt;int32&gt; CurrentQuestActorCode = GetCurrentQuest().QuestActorCode;

    // 액터 생성
    for (auto Code : CurrentQuestActorCode)
    {
        FQuestActorInfo* Data = QuestActorData-&gt;FindRow&lt;FQuestActorInfo&gt;(*FString::FromInt(Code), FString());

        FTransform QuestActorTransform;
        QuestActorTransform.SetLocation(Data-&gt;Position);
        QuestActorTransform.SetRotation(FQuat::MakeFromRotator(Data-&gt;Rotation));
        QuestActorTransform.SetScale3D(Data-&gt;Scale);

        QuestActorBox.Add(GetWorldRef().SpawnActor(Data-&gt;QuestActor, &amp;QuestActorTransform));
    }

    //UE_LOG(LogTemp, Warning, TEXT(&quot;Create Quest Actor&quot;));
}</code></pre>
<p>액터는 서버에서만 생성하도록 해주었다. 현재 퀘스트 데이터의 생성해야 할 액터 코드를 가져온 뒤, 퀘스트 액터 데이터 테이블에서 클래스를 받아와 생성해주는 코드이다. 생성한 뒤에는 따로 선언해준 QuestActorBox에 넣어준다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/8b5dbfe3-b7b1-4bef-874b-c035356d363d/image.png" alt=""></p>
<p>테스트를 위해 투명벽에 텍스트 렌더러를 따로 넣어주었다. 서버와 클라이언트 모두 첫번째 퀘스트에서 생성해주어야 하는 1, 2, 7번째 투명벽들을 잘 생성해준 모습을 볼 수 있다. </p>
<h2 id="퀘스트-체크-및-판정처리">퀘스트 체크 및 판정처리</h2>
<p>서브시스템은 RPC나 리플리케이트가 안되는 것 같다. 따라서, 기존의 퀘스트 매니저를 서브시스템에서 생성해 가지고 있도록 하고, 퀘스트 매니저를 이용해 네트워킹을 진행해보려고 한다.</p>
<p>어차피 게임 인스턴스를 통해 서브시스템을 찾아서 쓰기 때문에, 기존에 사용하던 델리게이트는 삭제해주었다.</p>
<pre><code class="language-cpp">void UQuestSubsystem::QuestCheck(int32 InQuestCode)
{
    // 항상 체크하는 퀘스트들
    for (auto AlwaysCheckQuest : AlwaysCheckQuestList)
    {
        // 이미 완료한 상태라면 Exit
        if (AlwaysCheckQuest-&gt;bUserComplete)
        {
            break;
        }

        // 퀘스트 코드가 같다면 Count 증가
        if (AlwaysCheckQuest-&gt;QuestCode == InQuestCode)
        {
            AlwaysCheckQuest-&gt;Count++;
        }

        // 목표 Count보다 높거나 같아졌다면
        if (AlwaysCheckQuest-&gt;Count &gt;= AlwaysCheckQuest-&gt;GoalCount)
        {
            AlwaysCheckQuest-&gt;bUserComplete = true;

            // 퀘스트 완료 서버 처리
        }
    }

    FQuest&amp; CurrentQuest = GetCurrentQuest();

    // 현재 퀘스트를 완료한 상태가 아니고 퀘스트 코드가 같다면 Count 증가
    if (!CurrentQuest.bUserComplete &amp;&amp; CurrentQuest.QuestCode == InQuestCode)
    {
        CurrentQuest.Count++;
    }

    // 목표 Count보다 높거나 같아졌다면
    if (CurrentQuest.Count &gt;= CurrentQuest.GoalCount)
    {
        CurrentQuest.bUserComplete = true;

        // 퀘스트 완료 처리
        ENetMode NetMode = GetWorld()-&gt;GetNetMode();

        // 서버이고, 팀도 클리어 했을 경우 퀘스트 완료판정
        if (NetMode == NM_ListenServer)
        {
            if (CurrentQuest.bTeamComplete)
            {
                QuestComplete();
            }
            else
            {
                // 클라이언트에게 완료 알리기
            }
        }
        else
        {
            // 서버에게 완료 알리기
        }
    }

    // UI 변경
    SetQuestUI();
}

void UQuestSubsystem::QuestComplete()
{
    PlayQuestCompleteSound();

    // 정비시간으로 이동
    if (CurrentQuestIndex == MoveMaintenanceQuestIndex)
    {
        //MyOwner-&gt;SetGameProgress(EGameProgress::Maintenance);
        //MyOwner-&gt;InitialItemSetting();
        // 서버와 클라이언트 모두 UI / 이펙트 OFF
        return;
    }

    CurrentQuestIndex++;

    // 마지막 퀘스트 클리어일 경우, 게임 클리어 판정
    if (CurrentQuestIndex == LastQuestIndex)
    {
        //MyOwner-&gt;SetGameProgress(EGameProgress::GameClear);
        return;
    }

    FQuest&amp; NewQuest = GetCurrentQuest();

    // 이미 클리어 했을 시 2초 뒤에 QuestComplete 호출
    if (NewQuest.bUserComplete &amp;&amp; NewQuest.bTeamComplete)
    {
        FTimerHandle TimerHandle;
        GetWorld()-&gt;GetTimerManager().SetTimer(TimerHandle, FTimerDelegate::CreateLambda([&amp;]
            {
                QuestComplete();
            }
        ), 1.0f, false, 2.0f);
    }

    // 현재 퀘스트 액터 정리
    DestroyQuestActor();

    // 다음 퀘스트 액터 준비
    CreateQuestActor();

    // 서버와 클라이언트 모두 UI / 이펙트 변경 처리
}</code></pre>
<p>주석으로 예쁘게 설명했으므로 설명은 생략(...) 중간중간 주석이 대체하고 있는 것들은 다른 것들 먼저 구현해놓고 하나씩 채워나갈 예정이다. 옮기다보니 치명적인 버그가 있던걸 확인했는데.... 지금까지 빌드에서는 너무 의도대로 잘 작동해서 몰랐다. 이렇게라도 발견해서 너무 다행이야,, ㅜㅜ</p>
<h2 id="퀘스트-액터-수정">퀘스트 액터 수정</h2>
<p>이제 새로 짠 로직에 맞게 기존에 만들어두었던 AInteract 관련 액터들을 수정해주어야 한다.</p>
<p>원래는 Interact 액터에서 두 캐릭터 모두 상호작용을 완료했는지 판정하고 퀘스트 완료처리를 해주었다. 이제는 퀘스트 액터들이 로컬 플레이어의 퀘스트 완료 여부만 처리하고, 퀘스트 서브시스템에서 두 플레이어가 모두 완료했는지 확인 후 퀘스트 완료판정을 내리도록 바꿔줄 것이다.</p>
<h3 id="ainteract">AInteract</h3>
<pre><code class="language-cpp">UCLASS()
class TRAPPERPROJECT_API AInteract : public AActor
{
    GENERATED_BODY()

public:    
    // Sets default values for this actor&#39;s properties
    AInteract();

protected:
    // Called when the game starts or when spawned
    virtual void BeginPlay() override;

public:    
    // Called every frame
    virtual void Tick(float DeltaTime) override;
    virtual void CheckActivate(int32 Value);

protected:
    uint8 bPlayerActive : 1;
};

void AInteract::CheckActivate(int32 Value)
{
    if(bPlayerActive) return;

    UQuestSubsystem* QuestSubSystem = GetWorld()-&gt;GetSubsystem&lt;UQuestSubsystem&gt;();
    QuestSubSystem-&gt;QuestCheck(Value);
    bPlayerActive = true;
}</code></pre>
<p><code>CheckActivate</code> 함수와 <code>bPlayerActive</code> 변수를 제외하고 모두 삭제했다.</p>
<pre><code class="language-cpp">void ATrapperPlayer::FClickStarted(const FInputActionValue&amp; Value)
{
    if (!IsLocallyControlled()) return;

    // 라인 트레이싱 부분 생략...

    if (HasHit)
    {
        AInteract* InteractActor = Cast&lt;AInteract&gt;(HitResult.GetActor());
        if (!InteractActor) return;

        if (HasAuthority())
        {
            MulticastRPCPlayInstallAnim();
        }
        else
        {
            InteractActor-&gt;CheckActivate(30);
            ServerRPCPlayInstallAnim();
        }
    }
}</code></pre>
<p>플레이어에서 해당 액터에 상호작용 했을 때, 서버일 경우 애니메이션만 재생해주고 클라이언트에서 퀘스트 완료 판정 처리를 해줬다.</p>
<h3 id="amovequesttriggerbox">AMoveQuestTriggerBox</h3>
<pre><code class="language-cpp">UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
uint8 bCheckMagneticMoving : 1;

void AMoveQuestTriggerBox::NotifyActorBeginOverlap(AActor* OtherActor)
{
    Super::NotifyActorBeginOverlap(OtherActor);

    ATrapperPlayer* Player = Cast&lt;ATrapperPlayer&gt;(OtherActor);
    if (!Player || !Player-&gt;IsLocallyControlled()) return;

    if (!bPlayerInTriggerTutorial)
    {
        // 트리거 발동 확인 코드
        CheckActivate(1);
    }
    else
    {
        // 자성 이동 확인해서 트리거 발동
        if (Player-&gt;Movement-&gt;GetMagneticMovingState())
        {
            CheckActivate(2);
        }
    }
}</code></pre>
<p><code>bCheckMagneticMoving</code> 변수만 남겨놓았다. 이 변수를 사용해 자성이동 트리거와 일반 접근 트리거로 나누어줬다.</p>
<h2 id="퀘스트-액터-블루프린트-생성">퀘스트 액터 블루프린트 생성</h2>
<p>기존에는 Tower가 AInteractive를 상속받아 동작하도록 했지만, 이제 타워 위에 상호작용 박스를 생성하여 상호작용이 동작하도록 만들어줄 것이다. 자성이동용 트리거 박스도 만들어주어야 한다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/1ef2de99-096b-49a2-8f71-72c17d738a30/image.png" alt=""></p>
<p>Interact 콜리전 채널만 Block해준 상호작용 박스를 만들고,</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/1990085c-6869-4755-a0b5-18a2608c28be/image.png" alt=""></p>
<p>기존 이동 트리거 박스를 복제해 자성이동을 체크하도록 만들어주었다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/5c3a1f73-61fb-43ba-8879-e194e84e2b0a/image.png" alt=""></p>
<p>다 만든뒤 데이터 테이블에 수정된 것들을 모두 반영해줬다.</p>
<hr>
<p>이틀동안은 구조를 고민하고, 큰 그림을 그려두었다. 아직 많이 부족하긴 하지만, 내일부터는 네트워크부터 해서 점차적으로 디테일한 부분들을 바꿔나가야겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UE5] 재질별 캐릭터 발소리 구현 & 배경음악 넣기]]></title>
            <link>https://velog.io/@nyong_u_u/DAY35-%EC%9E%AC%EC%A7%88%EB%B3%84-%EC%BA%90%EB%A6%AD%ED%84%B0-%EB%B0%9C%EC%86%8C%EB%A6%AC-%EA%B5%AC%ED%98%84-%EB%B0%B0%EA%B2%BD%EC%9D%8C%EC%95%85-%EB%84%A3%EA%B8%B0</link>
            <guid>https://velog.io/@nyong_u_u/DAY35-%EC%9E%AC%EC%A7%88%EB%B3%84-%EC%BA%90%EB%A6%AD%ED%84%B0-%EB%B0%9C%EC%86%8C%EB%A6%AC-%EA%B5%AC%ED%98%84-%EB%B0%B0%EA%B2%BD%EC%9D%8C%EC%95%85-%EB%84%A3%EA%B8%B0</guid>
            <pubDate>Thu, 18 Jul 2024 02:29:15 GMT</pubDate>
            <description><![CDATA[<h2 id="재질별-캐릭터-발소리-구현">재질별 캐릭터 발소리 구현</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/27bfe1c1-65bc-4696-8b2b-1c7d0142a916/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/1b428170-2a22-40c0-8a4b-63474ee67275/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/a7c22989-0828-4d31-b0bc-acf86163902a/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/1bf06cf5-990c-4eb1-9134-de9772d0b5dd/image.png" alt=""></p>
<p>트래퍼에는 이렇게 땅, 물, 콘크리트 세 종류의 바닥 재질이 있다. 재질별로 발소리를 다르게 해줘야 했다. 밑의 유튜브에서 설명이 잘 되어 있어서, 따라가며 작업했다. 나중에 또 필요할 때를 대비해 여기에 정리해두겠다 :)</p>
<blockquote>
<p><a href="https://youtu.be/TzGqRIioBbY?si=KrwM0WA1dYWcbWyO">https://youtu.be/TzGqRIioBbY?si=KrwM0WA1dYWcbWyO</a></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/b0606dfa-7acd-4299-a614-7ceb83c31c45/image.png" alt=""></p>
<p>먼저 해당하는 사운드 큐를 만들어줘야한다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/8ce74d6c-dca2-4e75-b08c-c99bb9444ade/image.png" alt=""></p>
<p>모든 사운드 큐는 내부적으로 여러개의 사운드를 랜덤으로 재생한다. </p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/61ca4520-9067-4b68-a7dc-83edb916b273/image.png" alt=""></p>
<p>사용할 사운드 큐를 모두 만들었다면, 프로젝트 세팅의 Engine - Physics - Physics Surface 로 이동해 이름을 지어주면 된다. 나는 세개를 사용할 것이므로, Ground / Water / Concrete 이렇게 사용해주었다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/9fb93fcb-4eae-461c-a0e6-e4c1f939e0cb/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/81212f62-7b8b-4761-8c32-ef909adc3aa3/image.png" alt=""></p>
<p>이제 피직스 머테리얼을 만들어주자. 각각 머테리얼마다 아까 만들어준 Surface Type을 설정해주면 된다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/6d5968b5-66fe-4358-8887-322c1e07fc7a/image.png" alt=""></p>
<p>레벨에 배치된 캐릭터가 걸어다닐 액터들을 선택하고 방금 만들어준 피직스 머테리얼을 설정해주었다.</p>
<p>발소리는 몽타주 내에서 이벤트로 처리해줄 것이기 때문에, AnimNotify를 상속받은 블루프린트를 만들어주자.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/19acac7a-7667-455f-b3a6-95a20c3dbcd9/image.png" alt=""></p>
<p>Override - Received Notify를 선택해주면, 이제 노드를 구현할 차례이다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/b7b099cf-91c8-4c00-b2c7-44facf8812cd/image.png" alt=""></p>
<p>먼저 Mesh Comp에서 Owner를 받아와준 뒤, 액터의 로케이션을 가져와 새로운 벡터를 만들어 줘야한다. Z값에 캐릭터로부터 캐릭터가 밟고있는 지면까지 레이를 쏠 수 있도록 적당한 값을 빼주면 되는데, 나는 캐릭터의 캡슐 컴포넌트를 가져와 빼주고 추가로 작은 값을 더 빼주었다. 만약 소리가 나지 않는다면 디버그 라인을 사용해 지면에 레이가 정확히 히트되는지 파악해보는걸 추천 :)</p>
<p>라인 트레이스의 Start에 액터의 위치를 넣어주고, End에는 방금 만들어준 벡터를 연결해주자. 여기서 캐릭터를 Actors to Ignore에 추가해주는게 중요하다. 이걸 해주지 않으면,</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/62b1fa5a-8068-4493-bfa6-2b84bdac6627/image.png" alt=""></p>
<p>라인 트레이스에서 캐릭터를 판정해버려 그냥 디폴트로 설정한 소리가 나게 된다. </p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/74a27cd3-4e29-4c7e-9684-a56ced0c25f1/image.png" alt=""></p>
<p>Actors to Ignore 배열을 정확히 지정해주면 이렇게 바닥에 잘 히트되는 것을 볼 수 있음!</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/5f6a4656-abf5-44f4-9b8f-405c5b7f99b1/image.png" alt=""></p>
<p>이제 Branch를 통해 히트한 결과가 있다면 사운드 재생, 없다면 리턴해주면 된다.</p>
<p>Out Hit에서 Surface Type을 받아와 Select 노드의 인덱스로 넣어주고, 종류에 따라 사운드를 처리해주면 된다. Spawn Attached 노드의 Location에는 히트한 위치를 넣어주고, Sound에는 방금 Select에서 리턴된 값을 넣어주면 된다. </p>
<p>사실 로직 자체가 간단해서 C++로 구현해볼까 했지만.. 귀찮았다..ㅎㅎ 언젠가 시간이 있으면 바꿔보는걸로..</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/6cde5302-05b9-46ab-b3c7-7224c7f7b759/image.png" alt=""></p>
<p>이제 애니메이션 시퀀스나 몽타주에 내가 만든 블루프린트를 노티파이로 추가해주면 끝!</p>
<h2 id="물-발소리가-나지-않는-현상-해결">물 발소리가 나지 않는 현상 해결</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/e3dea8b6-c743-45d2-b9ab-5a38c75ca81a/image.png" alt=""></p>
<p>왜인지 모르겠지만.. 나는 다 적용하고 나서도 캐릭터가 걸을 때 물 발자국 소리가 나질 않고, 땅 발자국 소리만 계속 났다. 라인으로 디버그를 찍어봐도 물쪽에 Hit 처리가 안되는걸 보니 콜리전 채널이 문제인가 싶었는데, 라인트레이스 채널을 Block으로 바꿔줘도 같은 현상이 일어났었다. 이것저것 옵션을 바꿔봐도 해결이 안되어서, 일단은 피직스 볼륨을 사용해 예외적으로 처리해주었다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/3d4b7905-d1db-4d0e-bcf5-23a1870d236b/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/19f1743f-a55a-44d3-8bb3-e6976450df89/image.png" alt=""></p>
<p>피직스 볼륨을 물이 있는 쪽에 넓게 깔아주면 정상적으로 트레이스되며 물 발소리가 재생된다. 땅 재질보다 살짝 낮게 깔아두어서 땅 위로 올라가면 정상적으로 땅 발소리가 재생된다!</p>
<h2 id="배경음악-재생하기">배경음악 재생하기</h2>
<p>우리 게임은 레벨마다 배경음악이 다르므로, 레벨이 배경음악 재생을 맡도록 했다. </p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/98d367b4-4d6d-41fc-bcc8-5a23ab9cce16/image.png" alt=""></p>
<p>C++ 코드에서 배경음악 재생을 명령할 것이므로, LevelScriptActor를 상속받은 TrapperScriptActor를 하나 만들어주었다.</p>
<pre><code class="language-cpp">UCLASS()
class TRAPPERPROJECT_API ATrapperScriptActor : public ALevelScriptActor
{
    GENERATED_BODY()

public:
    UFUNCTION(BlueprintImplementableEvent)
    void ChangeBackgroundSound(float FadeOutTime);

    virtual void BeginPlay() override;
};

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

    ChangeBackgroundSound(0.f);
}</code></pre>
<p><code>BlueprintImplementableEvent</code> 로 함수를 하나 만들어주었고, <code>BeginPlay</code> 함수에서는 맨 처음 배경음악을 재생하기 위해 해당 함수를 호출해주었다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/efdeef21-3115-4a78-8ef2-b220ff48bea7/image.png" alt=""></p>
<p>트래퍼 레벨의 Parent Class를 방금 만든 TrapperScriptActor로 지정해주고,</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/41bf2788-00ea-44d4-8cc3-7568f51da6ed/image.png" alt=""></p>
<p>받은 FadeOutTime 값에 따라 Fade Out을 진행, </p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/bcd9c2c4-53b7-46cf-9fbc-d8bc335f9a1e/image.png" alt=""></p>
<p>레벨에서 재생을 담당할 오디오 컴포넌트를 가진 BGM Manager 액터를 하나 만들어 준 뒤 타겟으로 넣어주었다.</p>
<p>지정한 시간이 끝난 뒤에 Change BGM 함수를 실행한다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/c1ce6968-d1fd-4d7c-9e46-0fcbd9125ac1/image.png" alt=""></p>
<p>게임 스테이트를 캐스팅한 후, 현재 Game Progress를 가져와 해당 모드에 따라 BGM을 골라서 재생하도록 만들어주었다. 
(이거때문에 게임모드에 있던 Current Game Progress 변수를 Game State로 옮기는 과정도 거쳤다. 언제 다 옮기지.. 번거로우니까 포스팅에서는 생략하겠다)</p>
<pre><code class="language-cpp">void ATrapperGameMode::SetGameProgress(EGameProgress Progress)
{
    EGameProgress PreviousProgress = TrapperGameState-&gt;CurrentGameProgress;

    if (PreviousProgress != Progress)
    {
        TrapperGameState-&gt;CurrentGameProgress = Progress;
        TrapperGameState-&gt;OnRep_GameProgressSetting();
    }

// 생략...

void ATrapperGameState::OnRep_GameProgressSetting()
{
    UE_LOG(LogTemp, Warning, TEXT(&quot;Change Background&quot;));

    ALevelScriptActor* LevelScriptActor = GetWorld()-&gt;GetLevelScriptActor();
    ATrapperScriptActor* MyLevelScriptActor = Cast&lt;ATrapperScriptActor&gt;(LevelScriptActor);
    if (MyLevelScriptActor)
    {
        MyLevelScriptActor-&gt;ChangeBackgroundSound(3.f);
    }
}</code></pre>
<p>CurrentGameProgress가 바뀔때마다 <code>ChangeBackgroundSound</code> 함수를 호출하도록 해주었다. OnRep 함수로 지정했기 때문에, 클라이언트에서도 잘 적용된다.</p>
<h2 id="효과음-적용">효과음 적용</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/cd77548f-5b0c-4b09-9638-6fe963746ba5/image.png" alt=""></p>
<p>캐릭터와 관련된 효과음들은 애니메이션 시퀀스나 몽타주에서 간단하게 설정할 수 있으므로, 캐릭터의 목소리, 점프, 솔루나 시프트 등 여러가지를 적용해주었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UE5] 플레이어 아웃라인 효과 구현]]></title>
            <link>https://velog.io/@nyong_u_u/DAY-34-%ED%94%8C%EB%A0%88%EC%9D%B4%EC%96%B4-%EC%95%84%EC%9B%83%EB%9D%BC%EC%9D%B8-%ED%9A%A8%EA%B3%BC-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@nyong_u_u/DAY-34-%ED%94%8C%EB%A0%88%EC%9D%B4%EC%96%B4-%EC%95%84%EC%9B%83%EB%9D%BC%EC%9D%B8-%ED%9A%A8%EA%B3%BC-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Tue, 16 Jul 2024 08:07:09 GMT</pubDate>
            <description><![CDATA[<p>게임 플레이 중, 팀원 플레이어의 가시성이 떨어져 팀원 플레이어가 어디있는지 찾기 힘든 상황이 생겼다. 따라서 아웃라인 처리와 함께 추가적인 UI가 새로 기획에 추가되었고, 오늘은 아웃라인을 작업하기로 했다.</p>
<p>기존 자성 기둥에 아웃라인 효과가 있으므로 똑같이 적용만 하면 될거라고 생각했지만, 아웃라인 굵기와 색상을 따로 지정해줘야하고 자성기둥과 달리 플레이어는 다른 액터들이 플레이어를 가리더라도 아웃라인이 표시되어야 했다.</p>
<h2 id="아웃라인-굵기-색상-설정하기">아웃라인 굵기, 색상 설정하기</h2>
<p>스탠실 값을 사용하여 아웃라인의 설정을 바꿔주기로 했다. </p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/4d74ce97-50fc-4512-b079-907f51511b33/image.png" alt=""></p>
<p>먼저 프로젝트 세팅에서 Enabled with Stencil로 옵션을 바꿔주고, Mesh의 CustomDepth Stencil Value를 설정해주었다.</p>
<pre><code class="language-cpp">if (IsLocallyControlled())
{
    GetMesh()-&gt;SetRenderCustomDepth(false);
}</code></pre>
<p>플레이어는 2, 자성기둥은 0으로 설정한 뒤, 플레이어의 BeginPlay 함수에서 로컬플레이어만 RenderCustomDepth 설정을 false로 바꿔주자.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/5eed85e2-e70c-493c-a65b-c9250cd2c667/image.png" alt=""></p>
<p>커스텀 스텐실 값은 SceneTexture의 CustomStencil을 선택해 R값으로 마스킹하여 가져올 수 있다. Named Reroutes(이름 경유) 노드를 만들어 어디서든 꺼내쓰기 쉽게끔 만들어주자.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/e406b928-517f-4856-9ad9-2ac55f8cbf67/image.png" alt=""></p>
<p>기존 아웃라인의 굵기를 설정하는 곳을 If문을 사용해 이렇게 바꿔주었다. 스텐실 값이 1보다 크다면 자성기둥의 굵기 변수를 사용하도록 하고, 1보다 작다면 캐릭터의 굵기 변수를 사용하도록 했다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/63bcbd95-501e-4661-b337-7348245ac8ce/image.png" alt=""></p>
<p>마찬가지로 굵기와 같은 로직을 사용하여 자성 기둥에는 빨간색, 캐릭터는 파란색이 보이도록 해주었다. <code>A==B</code> 에 연결된 Scene Texture 노드는 곧 설명하도록 하겠음!</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/349e2a3b-cd71-4e4c-baa6-e91b8ab22f70/image.png" alt=""></p>
<p>그럼 스텐실 값에 따라 이렇게 다른 색상과 굵기를 가질 수 있게 된다.</p>
<h2 id="물체-뒤에서도-아웃라인-보이게-하기">물체 뒤에서도 아웃라인 보이게 하기</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/c4be46c3-11c2-4fd3-8ded-2d262da11004/image.gif" alt=""></p>
<p>우리 게임에서 자성 기둥은 다른 물체에 가렸을 때 아웃라인이 보이지 않아야 하지만, 플레이어는 벽 뒤에 있어도 아웃라인이 보여야 한다. 위의 gif를 보면 자성기둥과 같이 앞의 물체에 의해 가려지는 것을 볼 수 있음.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/cc1e74e9-23fd-4985-9bd6-8e0deaaaf3ae/image.png" alt=""></p>
<p>기존에 사용하고 있던 씬 뎁스와 커스텀 뎁스를 사용해 앞에 물체가 있으면 아웃라인을 그리지 않는 부분을 Occulusion이라는 이름 경유 노드로 만들어 준 뒤,</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/c2e447e9-2817-432a-95f7-5336803cffe0/image.png" alt=""></p>
<p>스탠실 값에 따라 처리되도록 만들어주었다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/750a6d39-58ac-41c7-b985-0a8c00d97995/image.gif" alt=""></p>
<p>이렇게 물체 뒤에서도 잘 보이게 됐다.</p>
<h2 id="로컬-플레이어는-아웃라인이-뚫지-못하게-만들기">로컬 플레이어는 아웃라인이 뚫지 못하게 만들기</h2>
<p>뭔가 술술 잘 풀린다고 생각했지만.. (내 기준)치명적인 문제가 하나 있었다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/20a58e8f-0e29-4270-b660-c92ffb978e5c/image.png" alt=""></p>
<p>로컬 플레이어를 뚫고 그려버린다는 것.... 몬스터나 물체를 뚫는거야 기획상에서 당연히 해야되는 것이었지만.. 어떤 게임에서 캐릭터를 저렇게 처리할까?</p>
<p>어떤 물체에 가려졌을 때만 뜬다면 굳이 처리할 필요 없겠지만, 아웃라인이 &#39;항상&#39; 떠있도록 요청하셨기에 무조건 잡아야한다고 생각했다.</p>
<p>이것때문에 장장 몇시간을 서칭했는데도 별다른 방법을 찾지 못했다. 이것저것 따라해보고 방법도 고민해보고 하다가, 아까 로컬플레이어를 <code>SetRenderCustomDepth(false)</code> 로 바꿔줬던걸 이것저것 건들여보는김에 이걸 true로 켜봤다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/bb2a2620-dfc9-484e-b648-e346860f2693/image.png" alt=""></p>
<p>흠.... 상대방 아웃라인이 안뚫리네?? 그럼 커스텀 뎁스를 켜둔 상태에서 아웃라인을 안그리면 되지 않을까...?</p>
<pre><code class="language-cpp">if (IsLocallyControlled())
{
    GetMesh()-&gt;SetCustomDepthStencilValue(1);
}</code></pre>
<p>플레이어의 BeginPlay에서 로컬 플레이어만 스텐실 값을 1로 바꿔주고,</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/a834c568-41a3-44ec-9cac-8d68b8244c07/image.png" alt=""></p>
<p>스텐실 값이 1일 경우에는 씬 텍스쳐를 그대로 그리도록 넣어줬다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/96f96aa9-1d7c-4d60-a418-178ec1d200a3/image.gif" alt=""></p>
<p>엇... 이게 되네...??? 생각보다 꽤 잘 되길래 놀랬다.</p>
<h2 id="기획--아트분들께-전달">기획 / 아트분들께 전달</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/6a8d6c73-9876-456c-baa5-96ef067c8f96/image.png" alt=""></p>
<p>아무래도 이부분은 아트와 기획쪽에서 건들여보고 값을 지정하는게 좋을 것 같아서, 외부에서 접근해 굵기와 색상을 바꿀 수 있도록 해두고 전달드렸다 :) </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UE5] 스킵, 남은 몬스터 수 UI 구현]]></title>
            <link>https://velog.io/@nyong_u_u/DAY-33-%EC%8A%A4%ED%82%B5-%EB%82%A8%EC%9D%80-%EB%AA%AC%EC%8A%A4%ED%84%B0-%EC%88%98-UI-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@nyong_u_u/DAY-33-%EC%8A%A4%ED%82%B5-%EB%82%A8%EC%9D%80-%EB%AA%AC%EC%8A%A4%ED%84%B0-%EC%88%98-UI-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Mon, 15 Jul 2024 07:20:50 GMT</pubDate>
            <description><![CDATA[<h2 id="skip-ui-위젯-만들기">Skip UI 위젯 만들기</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/a7a6e5f6-a3f0-4b06-afa1-0d787b71bfaf/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/126f7658-f2b1-4317-a0f7-78c018925df3/image.png" alt=""></p>
<p>항상 제일 오래걸리는 위젯 만들기.. 너무너무 어렵다.. 이번에도 어떻게 얼레벌레 만들어냈는데, 나중에 위젯 관련된 강의가 없는지 찾아봐야겠다. 알고 만들고 싶어..</p>
<p>아무튼, 이번에는 기존에 만들어두었던 안내창의 Vertical Layout에 SizeBox를 추가하고 자식으로 Overlay를 추가한 뒤, 이미지와 텍스트, Radial Slider를 사용해 UI를 제작해주었다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/2a1fe2eb-1a94-4912-8b2a-a005ee2a315e/image.png" alt=""></p>
<p>1P에 해당하는 붉은색 바는 Value가 증가하면 시계방향으로 차도록 설정해주었고, </p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/0610baa1-9227-49d9-8708-5df41c1fceb7/image.png" alt=""></p>
<p>2P에 해당하는 파란색 바는 Value가 감소하면 차도록 설정해주었다. 이렇게 구현한 이유는 별거없다.. 아무리 옵션을 이것저것 만져봐도 반시계 방향으로 차오르는걸 구현할 수가 없어서.. ㅎㅎ</p>
<p>스킵이 가능할 때만 보여져야 하므로, 가장 최상위 패널인 SizeBox와 Value를 조작해야 하는 Radial Slider 두개만 변수로 승격해주었다.</p>
<h2 id="구현">구현</h2>
<h3 id="스킵-기능-구현">스킵 기능 구현</h3>
<p>기존에는 F키가 신전 상호작용에만 한번 누르는 정도로만 사용하고 있었어서, Input Action을 변경해주었다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/f0ca5d3a-a4d6-4588-a146-55aeb78248bf/image.png" alt=""></p>
<p>Trigger는 Hold And Release로, Hold Time Threshold는 2.0초로 해주자. (Threshold에 지정한 시간이 지나야 Complete 처리가 된다!)</p>
<pre><code class="language-cpp">// ATrapperPlayer.h

void FClickStarted(const FInputActionValue&amp; Value);
void FClickOngoing(const FInputActionValue&amp; Value);
void FClickCanceled(const FInputActionValue&amp; Value);
void FClickCompleted(const FInputActionValue&amp; Value);

UPROPERTY(ReplicatedUsing = OnRep_SkipGauge)
float SkipGauge;

float SkipAccumulatelTime;
float SkipTime = 2.f;

UFUNCTION() void OnRep_SkipGauge();

void CalculateSkipGauge();
bool SkipCurrentStage();

UFUNCTION(Server, Unreliable)
void ServerRPCSkipGaugeChange(float Value);

UFUNCTION(Server, Reliable)
void ServerRPCSkip();</code></pre>
<p>스킵 기능을 위해 추가로 구현한 함수와 변수들이다. 막상 이렇게 적어두니 뭔가 엄청 많아보이네(..)</p>
<pre><code class="language-cpp">UIC-&gt;BindAction(FClickAction, ETriggerEvent::Started, this, &amp;ATrapperPlayer::FClickStarted);
UIC-&gt;BindAction(FClickAction, ETriggerEvent::Ongoing, this, &amp;ATrapperPlayer::FClickOngoing);
UIC-&gt;BindAction(FClickAction, ETriggerEvent::Canceled, this, &amp;ATrapperPlayer::FClickCanceled);
UIC-&gt;BindAction(FClickAction, ETriggerEvent::Completed, this, &amp;ATrapperPlayer::FClickCompleted);</code></pre>
<p>먼저, 트리거 이벤트에 맞추어 만든 함수들을 바인딩해주었다. 처음에 눌렀을 때, 누르고 있을 때, 누르다 뗐을 때 성공했을 경우와 실패했을 경우 이렇게 네가지 조건이 필요했다.</p>
<pre><code class="language-cpp">void ATrapperPlayer::FClickStarted(const FInputActionValue&amp; Value)
{
    if (!IsLocallyControlled()) return;

    if (SkipGauge &gt;= 1.f)
    {
        if (HasAuthority())
        {
            SkipGauge = 0.f;
            OnRep_SkipGauge();
        }
        else
        {
            ServerRPCSkipGaugeChange(0.f);
        }
    }

    // 생략..
}</code></pre>
<p>F키를 처음 눌렀을 때 실행되는 함수인 <code>FClickStarted</code> 에서는, 스킵판정 처리가 난 후 F키를 한번 더 누르면 초기화해주는 코드를 넣어두었다.</p>
<pre><code class="language-cpp">void ATrapperPlayer::ServerRPCSkipGaugeChange_Implementation(float Value)
{
    SkipGauge = Value;
    OnRep_SkipGauge();
}</code></pre>
<p>서버에서 바뀌는건 게이지 값이 리플리케이트되고 있지만, 클라이언트에서 바뀌는 값은 서버에서 알 수 없으므로 Server RPC를 통해 변경해준다. </p>
<pre><code class="language-cpp">void ATrapperPlayer::FClickOngoing(const FInputActionValue&amp; Value)
{
    SkipAccumulatelTime += GetWorld()-&gt;GetDeltaSeconds();
    float Gauge = SkipAccumulatelTime / SkipTime;
    Gauge = FMath::Clamp(Gauge, Gauge, 1);

    if (HasAuthority())
    {
        SkipGauge = Gauge;
        OnRep_SkipGauge();
    }
    else
    {
        ServerRPCSkipGaugeChange(Gauge);
    }
}</code></pre>
<p>F키가 눌린 상태에서 호출되는 <code>FClickOngoing</code> 함수에서는 SkipAccumulatelTime 변수에 델타타임을 누적해주고, UI 게이지를 계산해 SkipGauge 변수에 넣어준다. SkipTime은 스킵 판정이 이루어지기까지 필요로 하는 시간이며, 지금은 기획자분들이 지정해두신 2.0초를 사용하고 있다. UI에 넣어주어야 하는 값이기 때문에, 누적된 시간 값을 0~1 사이로 노말라이징해 사용하고 있다.</p>
<pre><code class="language-cpp">void ATrapperPlayer::FClickCanceled(const FInputActionValue&amp; Value)
{
    SkipAccumulatelTime = 0.f;

    if (HasAuthority())
    {
        SkipGauge = 0.f;
        OnRep_SkipGauge();
    }
    else
    {
        ServerRPCSkipGaugeChange(0.f);
    }
}</code></pre>
<p>버튼을 누르고 2초가 지나지 않고 뗐을 경우 호출되는 함수이다. SkipAccumulatelTime 변수를 초기화해주고, 게이지를 0으로 되돌린다.</p>
<pre><code class="language-cpp">void ATrapperPlayer::FClickCompleted(const FInputActionValue&amp; Value)
{
    SkipAccumulatelTime = 0.f;

    if (HasAuthority())
    {
        SkipGauge = 1.f;

        if (SkipCurrentStage())
        {
            SkipGauge = 0.f;
        }

        OnRep_SkipGauge();
    }
    else
    {
        ServerRPCSkip();
    }
}

void ATrapperPlayer::ServerRPCSkip_Implementation()
{
    SkipCurrentStage();
}</code></pre>
<p>2초가 지난 후 버튼을 떼면 스킵이 가능한 상태가 된다. 게이지 값을 1로 고정시킨다. </p>
<pre><code class="language-cpp">bool ATrapperPlayer::SkipCurrentStage()
{
    TArray&lt;AActor*&gt; OutActors;
    UGameplayStatics::GetAllActorsOfClass(GetWorld(), ATrapperPlayer::StaticClass(), OutActors);

    ATrapperPlayer* FirstPlayer = nullptr;
    ATrapperPlayer* SecondPlayer = nullptr;

    for (const auto&amp; Pawn : OutActors)
    {
        ATrapperPlayer* TrapperPlayer = Cast&lt;ATrapperPlayer&gt;(Pawn);
        if (!TrapperPlayer) continue;

        if (TrapperPlayer-&gt;IsLocallyControlled())
        {
            FirstPlayer = TrapperPlayer;
        }
        else
        {    
            SecondPlayer = TrapperPlayer;
        }
    }

    if(!IsValid(FirstPlayer) || !IsValid(SecondPlayer)) 
    {
        return false;
    }

    if ((FirstPlayer-&gt;SkipGauge &lt; 1.f) || (SecondPlayer-&gt;SkipGauge &lt; 1.f))
    {
        return false;
        UE_LOG(LogTemp, Warning, TEXT(&quot;Skip Failed&quot;));
    }

    FirstPlayer-&gt;SkipGauge = 0.f;
    SecondPlayer-&gt;SkipGauge = 0.f;

    AGameModeBase* GameMode = UGameplayStatics::GetGameMode(GetWorld());
    ATrapperGameMode* MyGameMode = Cast&lt;ATrapperGameMode&gt;(GameMode);
    if(!MyGameMode) return false;

    EGameProgress CurrentStage = MyGameMode-&gt;CurrentGameProgress;
    switch (CurrentStage)
    {
    case EGameProgress::Tutorial:
        MyGameMode-&gt;SkipTutorial();
        break;
    case EGameProgress::Maintenance:
        MyGameMode-&gt;SkipMaintenanace();
        break;
    default:
        break;
    }

    return true;
}</code></pre>
<p><code>FClickCompleted</code> 함수 내에서 <code>SkipCurrentStage</code> 이라는 함수를 호출하고 있다. 이 함수는 두 플레이어를 받아와 게이지를 검사한 뒤, 두 플레이어 모두 스킵이 가능한 상태라면 게임모드의 Skip 함수를 호출해준다.</p>
<pre><code class="language-cpp">void ATrapperGameMode::SkipTutorial()
{
    InitialItemSetting();
    ActiveQuestActor(QuestManager-&gt;TutorialQuestEndNumber);
    QuestManager-&gt;QuestEffect-&gt;MulticastRPCSetQuestEffect(false, FVector::ZeroVector);
    SetGameProgress(EGameProgress::Maintenance);
}

void ATrapperGameMode::SkipMaintenanace()
{
    bSkipMaintenanace = true;
    bMaintenanceInProgress = false;

    SetSkipIcon(false);

    Wave++;
    SetGameProgress(EGameProgress::Wave);
}</code></pre>
<p><code>SkipTutorial()</code> 함수 내부에서는 처음에 정비에 필요한 아이템 지급, 퀘스트 액터 비활성화 처리, 퀘스트 이펙트 비활성화, 게임 상태를 정비로 넘겨주는 역할을 한다.</p>
<p><code>SkipMaintenanace()</code> 함수 내부에서는 정비를 종료하는 플래그를 설정해주고, 웨이브 단계에서는 스킵을 사용하지 않으므로 Skip UI를 안보이게 바꿔준 뒤 웨이브를 진행시킨다.</p>
<h3 id="ui-구현">UI 구현</h3>
<pre><code class="language-cpp">void ATrapperPlayer::OnRep_SkipGauge()
{
    for (FConstPlayerControllerIterator Iterator = GetWorld()-&gt;GetPlayerControllerIterator(); Iterator; ++Iterator)
    {
        ATrapperPlayerController* PC = Cast&lt;ATrapperPlayerController&gt;(Iterator-&gt;Get());
        if (PC)
        {
            PC-&gt;SetSkipGauge();
        }
    }
}

void ATrapperPlayerController::SetSkipGauge()
{
    if (IsLocalController() &amp;&amp; PlayerHudRef)
    {
        PlayerHudRef-&gt;SetSkipGauge();
    }
}</code></pre>
<p>OnRep 함수에서는 플레이어 컨트롤러의 함수를 호출해 UI를 변경해주고 있다.</p>
<pre><code class="language-cpp">void UPlayerHUD::SetSkipGauge()
{
    FindPlayer();

    if (Player-&gt;HasAuthority())
    {
        SkipGaugeFirstPlayer-&gt;SetValue(Player-&gt;SkipGauge);

        if (TeamPlayer)
        {
            SkipGaugeSecondPlayer-&gt;SetValue(1 - TeamPlayer-&gt;SkipGauge);
        }
        else
        {
            SkipGaugeSecondPlayer-&gt;SetValue(1.f);
        }
    }
    else
    {
        SkipGaugeSecondPlayer-&gt;SetValue(1 - Player-&gt;SkipGauge);
        SkipGaugeFirstPlayer-&gt;SetValue(TeamPlayer-&gt;SkipGauge);
    }

}</code></pre>
<p>기존 플레이어들의 체력 값을 받아오는 로직을 그대로 사용했다. <code>FindPlayer()</code> 함수를 사용해 플레이어를 받아오고, 스킵 게이지를 받아와 UI에 설정해준다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/4ed0900d-c724-4903-bf3d-752d22e70aa0/image.gif" alt=""></p>
<p>이제 튜토리얼과 정비시간을 스킵할 수 있게됐다 :)</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/34cfb39f-1a4e-4df2-b866-9529639f2859/image.png" alt=""></p>
<p>간단하긴 하지만, 테스트에 유용하게 쓰일 것 같아서 테스트하기 쉽도록 간단하게 정리해 기획자, 아트분들께 전달도 해드렸다.</p>
<h2 id="남은-몬스터-수-ui-구현">남은 몬스터 수 UI 구현</h2>
<p>위젯은 다른 팀원이 만들어주어서, 빠르게 구현만 진행할 수 있었다. </p>
<p>지금까지는 계속 게임모드에 모든 게임의 상태와 데이터를 관리하고 있었는데, 게임 스테이트라는 것을 알게 되었다... 여기에 구현했으면 조금 더 쉬웠을 텐데... 아무튼, 나중에 옮기더라도 구현이 우선이므로, 이거라도 게임 스테이트를 사용해보기로 했다.</p>
<p>게임 스테이트는 서버와 클라이언트 모두가 가지고 있으므로, 동시에 관리해야할 데이터를 가지고 있기 좋은 곳이었다.</p>
<pre><code class="language-cpp">// ATrapperGameState.h

public:
    void ChangeMonsterCount(int32 Count);
    void ChangeRemainingMonsterCount(int32 Count);

    UPROPERTY(ReplicatedUsing = OnRep_ChangeCurrentMonster)
    int32 CurrentMonsterCount = 0;

    UFUNCTION()
    void OnRep_ChangeCurrentMonster();

    UPROPERTY(ReplicatedUsing = OnRep_ChangeRemainingMonsterCount)
    int32 RemainingMonsterCount = 0;

    UFUNCTION()
    void OnRep_ChangeRemainingMonsterCount();</code></pre>
<pre><code class="language-cpp">void ATrapperGameState::GetLifetimeReplicatedProps(TArray&lt;FLifetimeProperty&gt;&amp; OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    DOREPLIFETIME(ATrapperGameState, CurrentMonsterCount);
    DOREPLIFETIME(ATrapperGameState, RemainingMonsterCount);
}</code></pre>
<p>현재 몬스터 수와, 앞으로 나올 몬스터의 수를 설정해주기 위하여 변수를 선언하고, 리플리케이트를 설정해주었다.</p>
<pre><code class="language-cpp">void ATrapperGameState::ChangeMonsterCount(int32 Count)
{
    if (HasAuthority())
    {
        CurrentMonsterCount += Count;
        OnRep_ChangeCurrentMonster();
    }
}

void ATrapperGameState::OnRep_ChangeCurrentMonster()
{
    if (PlayerHUDRef)
    {
        PlayerHUDRef-&gt;SetCurrentMonsterCount(CurrentMonsterCount);
    }
}</code></pre>
<p>몬스터가 생성되거나 삭제될 때 호출되는 함수들이다. 몬스터의 생명주기 관련 처리는 팀원 친구가 해놨으므로 패스하고, 웨이브별 남은 몬스터를 띄우는 부분만 정리하겠다.</p>
<pre><code class="language-cpp">void ATrapperGameState::ChangeRemainingMonsterCount(int32 Count)
{
    if (HasAuthority())
    {
        RemainingMonsterCount += Count;
        OnRep_ChangeRemainingMonsterCount();
    }
}

void ATrapperGameState::OnRep_ChangeRemainingMonsterCount()
{
    if (PlayerHUDRef)
    {
        PlayerHUDRef-&gt;SetRemainingMonsterCount(RemainingMonsterCount);
    }
}</code></pre>
<p><code>ChangeRemainingMonsterCount</code> 함수를 호출하면 앞으로 나올 몬스터 수에 가감되고 UI에 표시된다.</p>
<pre><code class="language-cpp">void ATrapperGameMode::GetThisWaveRemainingMonsterCount()
{
    for (uint32 i = Wave + 1; i &lt; Wave + 6; i++)
    {
        for (uint32 k = 1; k &lt; 6; k++)
        {
            FString WaveText = TEXT(&quot;Wave&quot;) + FString::FromInt(i) + TEXT(&quot;_&quot;) + FString::FromInt(k);
            FWaveInfo* Data = WaveData-&gt;FindRow&lt;FWaveInfo&gt;(*WaveText, FString());
            if (!Data || !Data-&gt;UseThisWave) continue;

            int32 TotalMonster = 0;

            TotalMonster += Data-&gt;Skeleton;
            TotalMonster += Data-&gt;Mummy;
            TotalMonster += Data-&gt;Zombie;
            TotalMonster += Data-&gt;Debuffer;

            TrapperGameState-&gt;ChangeRemainingMonsterCount(TotalMonster);
            //UE_LOG(LogTemp, Warning, TEXT(&quot;This Wave %d-%d, Total Spawn Monster %d&quot;), i, k, TotalMonster);
        }
    }
}</code></pre>
<p>게임모드에 <code>GetThisWaveRemainingMonsterCount</code> 함수를 추가해 정비시간마다 호출해주었다. 이 함수 안에서 앞으로 5웨이브동안 나올 몬스터 수를 계산해준다.</p>
<pre><code class="language-cpp">void ATrapperGameMode::SpawnMonster(struct FWaveInfo&amp; OutData)
{
    /// *********** Create Monster ***********
    if (!bSpawnMonster) return;

    UE_LOG(LogGameLoop, Warning, TEXT(&quot;Create Monster - Skeleton %d / Mummy %d / Zombie %d / Debuffer %d&quot;),
        OutData.Skeleton, OutData.Mummy, OutData.Zombie, OutData.Debuffer);

    int32 DeleteMonsterCount = 0;
    DeleteMonsterCount += OutData.Skeleton;
    DeleteMonsterCount += OutData.Mummy;
    DeleteMonsterCount += OutData.Zombie;
    DeleteMonsterCount += OutData.Debuffer;
    TrapperGameState-&gt;ChangeRemainingMonsterCount(-DeleteMonsterCount);

    if (IsValid(Spanwer))
    {
        Spanwer-&gt;SpawnMonsters(OutData.Skeleton, OutData.Mummy, OutData.Zombie, OutData.Debuffer);
        //UE_LOG(LogTemp, Warning, TEXT(&quot;Check&quot;));
    }

}</code></pre>
<p><code>SpawnMonster</code> 함수 내에서는 한 서브웨이브가 진행될 때마다 나오는 몬스터 수를 빼주도록 했다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/6d512f07-8144-4b7f-b3c7-70c91528b163/image.gif" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UE5] 구현했던 UI들의 구조 변경]]></title>
            <link>https://velog.io/@nyong_u_u/DAY-32-%EA%B5%AC%ED%98%84%ED%96%88%EB%8D%98-UI%EB%93%A4%EC%9D%98-%EA%B5%AC%EC%A1%B0-%EB%B3%80%EA%B2%BD</link>
            <guid>https://velog.io/@nyong_u_u/DAY-32-%EA%B5%AC%ED%98%84%ED%96%88%EB%8D%98-UI%EB%93%A4%EC%9D%98-%EA%B5%AC%EC%A1%B0-%EB%B3%80%EA%B2%BD</guid>
            <pubDate>Thu, 11 Jul 2024 12:39:55 GMT</pubDate>
            <description><![CDATA[<h2 id="기존-ui-구조-분석">기존 UI 구조 분석</h2>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/bda26c12-0caf-4601-86e3-244213a69583/image.png" alt=""></p>
<p>우선 내가 개선해야 할 UI들은 사진과 같다. &lt;신전의 HP, 아이템 갯수, 플레이어 HP, 퀘스트 안내창, 남은 몬스터 수&gt; 각각의 위젯마다 어떻게 동작하는지 도식화 해보기로 했다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/c16cedda-b7f5-42f4-831f-7221e5e1f916/image.png" alt=""></p>
<p>아직 남은 몬스터 수와 관련되어 처리해준 것은 없기 때문에 제외하고 분석해보았다. 퀘스트 UI를 제외하고는 모두 개선이 필요하다고 느꼈고, 이번 포스팅에서는 이걸 개선해나가는 과정을 적어보려고 한다.</p>
<h2 id="플레이어-피격판정-개선">플레이어 피격판정 개선</h2>
<p>UI 관련 코드들을 변경하기 전에 <code>TakeDamage</code> 구현부부터 변경해주기로 했다.</p>
<pre><code class="language-cpp">float ATrapperPlayer::TakeDamage(float DamageAmount, FDamageEvent const&amp; DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
    float Damage = Super::TakeDamage(DamageAmount, DamageEvent, EventInstigator, DamageCauser);
    if (IsDead) return Damage;

    Damage = FMath::Min(CurrentHP, Damage);

    if (HasAuthority())
    {
        CurrentHP -= Damage;
        MulticastCharacterDamaged(DamageCauser);
    }
    else if (IsLocallyControlled())
    {
        ServerRPCCharacterDamaged(DamageCauser, Damage);
    }

    return Damage;
}

void ATrapperPlayer::ServerRPCCharacterDamaged_Implementation(AActor* DamageCauser, float Damage)
{
    CurrentHP -= Damage;
    MulticastCharacterDamaged(DamageCauser);
}

void ATrapperPlayer::MulticastCharacterDamaged_Implementation(AActor* DamageCauser)
{
    OnRep_CurrentHP();

    if (HasAuthority() &amp;&amp; CurrentHP &lt;= 0)
    {
        MulticastCharacterDeath();
        return;
    }

    Alive(DamageCauser);
}</code></pre>
<p>기존 코드를 살펴보면, TakeDamage가 호출될 경우 서버에서는 CurrentHP에서 데미지만큼 체력을 깎고 Multicast RPC를 날려준다. 클라이언트일 경우 Server RPC를 날린다. 그러고 난 뒤 또 Multicast RPC를 날려준다.</p>
<p>타고타고 내려와서 Multicast에서 사망 혹은 생존판정을 내리게 되는데, 사망판정은 심지어 또 다른 Multicast RPC를 호출한다. 이 과정이 너무 복잡하다고 느꼈다.</p>
<h3 id="개선-후-코드">개선 후 코드</h3>
<pre><code class="language-cpp">float ATrapperPlayer::TakeDamage(float DamageAmount, FDamageEvent const&amp; DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
    float Damage = Super::TakeDamage(DamageAmount, DamageEvent, EventInstigator, DamageCauser);
    if (IsDead) return Damage;

    Damage = FMath::Min(CurrentHP, Damage);

    if (HasAuthority())
    {
        ServerTakeDamage(DamageCauser, Damage);
    }
    else if (IsLocallyControlled())
    {
        ServerRPCCharacterDamaged(DamageCauser, Damage);
    }

    return Damage;
}

void ATrapperPlayer::ServerTakeDamage(AActor* DamageCauser, float Damage)
{
    CurrentHP -= Damage;
    OnRep_CurrentHP();

    if (CurrentHP &lt;= 0)
    {
        MulticastRPCCharacterDeath();
        return;
    }

    MulticastRPCCharacterAlive(DamageCauser);
}</code></pre>
<p>TakeDamage() 구현부는 이렇게 바꿔주었다. 권한을 가질 경우 <code>ServerTakeDamage</code> 함수를 호출해주고, 아닐 경우 Server RPC를 통해 서버에서 <code>ServerTakeDamage</code> 함수를 호출해준다.</p>
<p>그리고 <code>ServerTakeDamage</code> 함수 내부에서 체력 상태를 확인해 Death, 혹은 Alive Multicast RPC를 날려준다.</p>
<h2 id="플레이어-ui-개선">플레이어 UI 개선</h2>
<p>우선 HP Change 델리게이트와 관련된 코드들을 삭제해주었다. 이제 OnRep_CurrentHP() 함수를 수정해줄 것이다.</p>
<pre><code class="language-cpp">void ATrapperPlayer::OnRep_CurrentHP()
{
    for (FConstPlayerControllerIterator Iterator = GetWorld()-&gt;GetPlayerControllerIterator(); Iterator; ++Iterator)
    {
        ATrapperPlayerController* PC = Cast&lt;ATrapperPlayerController&gt;(Iterator-&gt;Get());
        if (PC)
        {
            PC-&gt;SetPlayerHPBar();
        }
    }
}

void ATrapperPlayerController::SetPlayerHPBar()
{
    if (IsLocalController() &amp;&amp; PlayerHudRef)
    {
        PlayerHudRef-&gt;SetHPBar();
    }
}</code></pre>
<p>월드에 있는 플레이어 컨트롤러를 가져와, 각각의 <code>SetPlayerHPBar();</code> 함수를 호출하도록 했다. 그럼 정상적으로 HP UI를 바꿔준다 :)</p>
<pre><code class="language-cpp">void UPlayerHealthBar::SetHealthUI()
{
    FindPlayer();

    // ------------------------------------------------------------------------
    // Player

    float Health = Player-&gt;CurrentHP;
    float MaxHealth = Player-&gt;MaxHP;
    HealthText-&gt;SetText(FText::FromString(FString::FromInt(Health) + TEXT(&quot; / &quot;) + FString::FromInt(MaxHealth)));

    float HealthPercent = Health / MaxHealth;
    if (HealthPercent &lt;= 0.5)
    {
        HealthText-&gt;SetColorAndOpacity(FSlateColor(FColor::Red));
    }
    else
    {
        HealthText-&gt;SetColorAndOpacity(FSlateColor(FColor::White));
    }

    HealthBar-&gt;SetPercent(HealthPercent - 0.04);

    // ------------------------------------------------------------------------
    // Team Player

    if (!TeamPlayer)
    {
        TeamInfoCanvas-&gt;SetVisibility(ESlateVisibility::Collapsed);
        return;
    }

    TeamInfoCanvas-&gt;SetVisibility(ESlateVisibility::Visible);

    float TeamHealth = TeamPlayer-&gt;CurrentHP;
    float MaxTeamHealth = TeamPlayer-&gt;MaxHP;
    TeamHealthText-&gt;SetText(FText::FromString(FString::FromInt(TeamHealth) + TEXT(&quot; / &quot;) + FString::FromInt(MaxTeamHealth)));

    float TeamHealthPercent = TeamHealth / MaxTeamHealth;
    if (TeamHealthPercent &lt;= 0.5)
    {
        TeamHealthText-&gt;SetColorAndOpacity(FSlateColor(FColor::Red));
    }
    else
    {
        TeamHealthText-&gt;SetColorAndOpacity(FSlateColor(FColor::White));
    }

    TeamHealthBar-&gt;SetPercent(TeamHealthPercent - 0.04);
}</code></pre>
<p>게임을 시작했을 때 HP의 Max값을 받아와 변경해주기 위해 <code>SetHealthUI()</code> 구현부를 변경해주었다. </p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/625d3892-e34f-4903-ba4d-388ac3e6432d/image.png" alt=""></p>
<p>초기 설정을 확인하기 위해 기본 텍스트를 999/999로 바꾸어주자 :) 플레이어 컨트롤러의 <code>BeginPlay()</code> 함수 안에 있는 <code>InitializeHUD()</code> 함수에서 초기설정을 진행해주었다.</p>
<pre><code class="language-cpp">// MainHUD
if (IsValid(PlayerHudClass))
{
    PlayerHudRef = CreateWidget&lt;UPlayerHUD&gt;(this, PlayerHudClass);
    if (PlayerHudRef)
    {
        PlayerHudRef-&gt;AddToViewport();
        PlayerHudRef-&gt;SetVisibility(ESlateVisibility::Visible);
        PlayerHudRef-&gt;GetItemCountChanged(0);
        PlayerHudRef-&gt;SetHPBar();
        ATrapperGameState* gs = Cast&lt;ATrapperGameState&gt;(GetWorld()-&gt;GetGameState());
        if (gs)
        {
            gs-&gt;SetPlayerHUDRef(PlayerHudRef);
        }
    }
}</code></pre>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/d6c807c8-0862-4c39-bbdb-cc9414506278/image.png" alt=""></p>
<p>클라이언트는 정상적으로 세팅이 완료되었지만, 서버쪽의 팀 플레이어 HP 정보가 반영되지 않은것을 볼 수 있었다. 클라이언트가 접속한 다음 서버쪽에서도 다시 호출해주어야 한다.</p>
<pre><code class="language-cpp">UFUNCTION(Server, Reliable)
void ServerRPCAfterJoinClientSetting();

void ATrapperPlayerController::ServerRPCAfterJoinClientSetting_Implementation()
{
    // 클라이언트 접속 이후 서버의 로컬 캐릭터에서 처리해야할 것
    for (FConstPlayerControllerIterator Iterator = GetWorld()-&gt;GetPlayerControllerIterator(); Iterator; ++Iterator)
    {
        ATrapperPlayerController* PC = Cast&lt;ATrapperPlayerController&gt;(Iterator-&gt;Get());
        if (PC &amp;&amp; PC-&gt;IsLocalController())
        {
            PC-&gt;SetPlayerHPBar();
        }
    }
}</code></pre>
<p>클라이언트의 초기화 이후, 서버쪽에 클라이언트의 정보를 반영해주는 Server RPC를 만들어주었다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/a13b3a32-0b09-41d3-b78d-55c0bfe4df2f/image.gif" alt=""></p>
<p>클라이언트가 접속했을 때, 정상적으로 서버쪽에서 클라이언트의 정보가 동기화되어 UI가 생기는 것을 확인할 수 있다.</p>
<h2 id="신전-hp--wave-ui-개선">신전 HP / Wave UI 개선</h2>
<p>마찬가지로, 게임모드에 있는 신전의 정보들과 델리게이트 관련 코드들을 모두 삭제해주었다.</p>
<pre><code class="language-cpp">// PlayerHUD.h
void SetTowerInfo(float TowerHealth, float MaxTowerHealth, int32 CurrentWave, int32 MaxWave);</code></pre>
<pre><code class="language-cpp">void SetTowerHealth(float TowerHealth, float MaxTowerHealth);
void SetWaveInfo(int32 CurrentWave, int32 MaxWave);

void UPlayerHUD::SetTowerHealth(float TowerHealth, float MaxTowerHealth)
{
    FString TowerHealthString = FString::FromInt(TowerHealth) + TEXT(&quot; / &quot;) + FString::FromInt(MaxTowerHealth);
    TowerHealthText-&gt;SetText(FText::FromString(TowerHealthString));

    float HealthPercent = TowerHealth / MaxTowerHealth;

    if (HealthPercent &lt; 0.5)
    {
        TowerHealthText-&gt;SetColorAndOpacity(FColor::Black);
    }

    TowerHealthBar-&gt;SetPercent(HealthPercent - 0.04);
}

void UPlayerHUD::SetWaveInfo(int32 CurrentWave, int32 MaxWave)
{
    FString TowerWaveString = TEXT(&quot;Wave &quot;) + FString::FromInt(CurrentWave) + TEXT(&quot; / &quot;) + FString::FromInt(MaxWave);
    TowerWaveText-&gt;SetText(FText::FromString(TowerWaveString));
}</code></pre>
<p>원래 신전의 HP 정보를 변경할 때 Wave 정보도 함께 바꿔주었다. 게임모드와 신전 각각에서 접근해 변경할 수 있도록 두개의 함수로 분리해줬다.</p>
<pre><code class="language-cpp">// Tower.h

UPROPERTY(ReplicatedUsing = OnRep_HpChanged)
float CurrentHP;

UFUNCTION()
void OnRep_HpChanged();

// Tower.cpp

void ATower::GetLifetimeReplicatedProps(TArray&lt;FLifetimeProperty&gt;&amp; OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    DOREPLIFETIME(ATower, CurrentHP);
}</code></pre>
<p>먼저, Tower의 HP 변수를 리플리케이트 해줬다.</p>
<pre><code class="language-cpp">float ATower::TakeDamage(float DamageAmount, FDamageEvent const&amp; DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
    float Damage = Super::TakeDamage(DamageAmount, DamageEvent, EventInstigator, DamageCauser);
    Damage = FMath::Min(CurrentHP, Damage);

    if (HasAuthority())
    {
        CurrentHP -= Damage;
        OnRep_HpChanged();
        UE_LOG(LogTemp, Warning, TEXT(&quot;Tower Health : %f&quot;), CurrentHP);
    }

    return Damage;
}</code></pre>
<p>TakeDamage 함수가 호출되었을 때 서버에서만 현재 HP를 깎아주도록 했고, 서버에서는 OnRep_HpChanged 함수가 호출되지 않으므로 명시적으로 호출해주었다.</p>
<pre><code class="language-cpp">void ATower::OnRep_HpChanged()
{
    for (FConstPlayerControllerIterator Iterator = GetWorld()-&gt;GetPlayerControllerIterator(); Iterator; ++Iterator)
    {
        ATrapperPlayerController* PlayerController = Cast&lt;ATrapperPlayerController&gt;(Iterator-&gt;Get());
        if (PlayerController &amp;&amp; PlayerController-&gt;IsLocalController())
        {
            PlayerController-&gt;SetTowerHPBar(CurrentHP, MaxHP);
        }
    }
}

void ATrapperPlayerController::SetTowerHPBar(float CurrentHP, float MaxHP)
{
    if (IsLocalController() &amp;&amp; PlayerHudRef)
    {
        PlayerHudRef-&gt;SetTowerHealth(CurrentHP, MaxHP);
    }
}

void UPlayerHUD::SetTowerHealth(float TowerHealth, float MaxTowerHealth)
{
    FString TowerHealthString = FString::FromInt(TowerHealth) + TEXT(&quot; / &quot;) + FString::FromInt(MaxTowerHealth);
    TowerHealthText-&gt;SetText(FText::FromString(TowerHealthString));

    float HealthPercent = TowerHealth / MaxTowerHealth;

    if (HealthPercent &lt; 0.5)
    {
        TowerHealthText-&gt;SetColorAndOpacity(FColor::Black);
    }

    TowerHealthBar-&gt;SetPercent(HealthPercent - 0.04);
}</code></pre>
<p>OnRep_HpChanged() 함수가 실행되면, 레벨에 있는 플레이어 컨트롤러의 SetTowerHPBar 함수를 호출해준다.</p>
<pre><code class="language-cpp">void ATrapperPlayerController::InitializeWorldInfoHUD()
{
    // Tower Initialize
    TArray&lt;AActor*&gt; OutActors;
    UGameplayStatics::GetAllActorsOfClass(GetWorld(), ATower::StaticClass(), OutActors);

    for (const auto&amp; Actor : OutActors)
    {
        ATower* Tower = Cast&lt;ATower&gt;(Actor);

        if (Tower)
        {
            SetTowerHPBar(Tower-&gt;CurrentHP, Tower-&gt;MaxHP);
        }
    }</code></pre>
<p>초기 세팅은 아까와 같이 플레이어 컨트롤러의 InitializeHUD() 함수에서 InitializeWorldInfoHUD() 함수를 호출하고, 그 함수 안에서는 타워 정보를 받아와 최초의 신전 HP를 세팅해준다.</p>
<pre><code class="language-cpp">void ATrapperGameMode::SetWaveUI()
{
    for (FConstPlayerControllerIterator Iterator = GetWorld()-&gt;GetPlayerControllerIterator(); Iterator; ++Iterator)
    {
        ATrapperPlayerController* PlayerController = Cast&lt;ATrapperPlayerController&gt;(Iterator-&gt;Get());
        if (PlayerController)
        {
            PlayerController-&gt;MulticastRPCWaveSetting(Wave, MaxWave);
        }
    }
}</code></pre>
<p>현재 웨이브 정보는 퀘스트처럼 게임모드가 관리하므로, 퀘스트의 방법과 비슷하게 코드를 짜기로 했다.</p>
<pre><code class="language-cpp">// Quest &amp; Wave Initialize
if (HasAuthority())
{
    SetWorldInfo();
}
else
{
    ServerRPCSetWorldInfo();
}

void ATrapperPlayerController::SetWorldInfo()
{
    ATrapperGameMode* GameMode = Cast&lt;ATrapperGameMode&gt;(UGameplayStatics::GetGameMode(GetWorld()));
    GameMode-&gt;QuestManager-&gt;RequireSetQuestUI();
    GameMode-&gt;SetWaveUI();
}

void ATrapperPlayerController::ServerRPCSetWorldInfo_Implementation()
{
    SetWorldInfo();
}</code></pre>
<p>초기설정도 퀘스트와 마찬가지로, 플레이어 컨트롤러의 <code>InitializeWorldInfoHUD()</code> 함수 안에서 진행한다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/4508d940-c7ea-4ecb-b0cc-20c650527e50/image.png" alt=""></p>
<p>이번에도 확인을 위해 기본 텍스트를 9999/9999로 바꾸어주었다.</p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/c97c2d79-8dcf-4d3b-9e2e-5bcd29fb2e8e/image.gif" alt=""></p>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/e2ff103a-1785-424e-a4ed-f1bf6427460c/image.gif" alt=""></p>
<p>Max HP와 웨이브가 동기화 하는 모습, 몬스터에 의해 신전의 체력이 닳는 모습까지 잘 나오는 것을 확인했다 :)</p>
<h2 id="아이템-ui-개선">아이템 UI 개선</h2>
<p>마찬가지로 아이템과 관련된 델리게이트를 모두 지워주었고, 브로드캐스트 하는 쪽을 <code>PlayerController-&gt;SetItemCount(BoneItemBox);</code> 로 모두 바꿔주었다.</p>
<pre><code class="language-cpp">void ATrapperPlayerController::SetItemCount(int32 Value)
{
    if (IsLocalController() &amp;&amp; PlayerHudRef)
    {
        PlayerHudRef-&gt;SetItemCount(Value);
    }
}

void UPlayerHUD::SetItemCount(int32 Count)
{
    ItemText-&gt;SetText(FText::FromString(FString::FromInt(Count)));
}</code></pre>
<p>이 순서로 호출되어 UI를 바꿔주고, 초기 세팅은 HP Bar를 세팅하는 쪽에 함께 적어주었다.</p>
<pre><code class="language-cpp">    // MainHUD
    if (IsValid(PlayerHudClass))
    {
        PlayerHudRef = CreateWidget&lt;UPlayerHUD&gt;(this, PlayerHudClass);
        if (PlayerHudRef)
        {
            PlayerHudRef-&gt;AddToViewport();
            PlayerHudRef-&gt;SetVisibility(ESlateVisibility::Visible);

            PlayerHudRef-&gt;SetItemCount(0); // 여기

            PlayerHudRef-&gt;SetHPBar();
            ATrapperGameState* gs = Cast&lt;ATrapperGameState&gt;(GetWorld()-&gt;GetGameState());
            if (gs)
            {
                gs-&gt;SetPlayerHUDRef(PlayerHudRef);
            }
        }
    }</code></pre>
<p><img src="https://velog.velcdn.com/images/nyong_u_u/post/220f0e55-a9f8-4f42-9202-c34705c25e5b/image.gif" alt=""></p>
<p>아이템도 잘 먹어지는 것을 확인했다 :)</p>
<p>오늘은 계속 마음에 안들었던 UI 구조들을 개편할 수 있는 시간이었다. 여전히 어떤 구조가 좋은 코드인지는 잘 모르겠지만, 중구난방하게 사용되고 있던 코드들을 하나의 규칙을 잡고 정리한 것 같아 꽤나 만족스러운 하루였다.</p>
<p>내일은 아직 구현하지 않은 남은 몬스터 수 UI와 튜토리얼 / 정비시간 스킵 기능을 제작할 예정이다 :)</p>
]]></description>
        </item>
    </channel>
</rss>