<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>ryan_ur.log</title>
        <link>https://velog.io/</link>
        <description>🏦KAIST EE | 🏦SNU AI(빅데이터 핀테크 전문가 과정) | 📙CryptoHipsters 저자</description>
        <lastBuildDate>Sun, 16 Feb 2025 13:30:23 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>ryan_ur.log</title>
            <url>https://velog.velcdn.com/images/ryan_ur/profile/441e085d-e308-4aa1-aa38-203a96950136/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. ryan_ur.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/ryan_ur" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[UE ContentExamples #2]]></title>
            <link>https://velog.io/@ryan_ur/UE-ContentExamples-2</link>
            <guid>https://velog.io/@ryan_ur/UE-ContentExamples-2</guid>
            <pubDate>Sun, 16 Feb 2025 13:30:23 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@ryan_ur/UE-ContentExamples-1">저번 포스팅</a>에 이어서 이번에는 <code>UE ContentExamples</code>의 남은 데모들에 대해서 알아보려고 한다. 분량상 모든 부분을 다 다루지는 못하지만, 네트워크, 블루프린트, 나나이트, 파티클 시뮬레이션, 2D sprite의 인상적이였던 데모들을 기록해 보았다. </p>
<hr>
<h3 id="unreal-network">Unreal Network</h3>
<p><code>Unreal Network</code>에서는 다른 Map들과 Player 2명에 Netmode를 Listen Server로 두어 client 화면 1개, server 화면 1개로 만들고 데모를 돌려보았다. 솔직히 이 프로젝트에서 네트워크까지 다뤄줄지 몰랐는데 감사하다(네트워크 데모쪽 분량이 적은건 살짝 아쉽다)</p>
<p>첫번째는 <code>Variable Replication</code>이다. 2명의 유령이 있는데 각 유령 위에는 체력이 일정 시간마다 깎인다. 두 유령의 차이가 있다면 왼쪽 유령은 체력 변수가 <code>Replicate</code>되지 않고 오른쪽은 된다. <code>Replicate</code>되지 않는 변수는 변수를 감소시키는 로직이 들어있는 서버쪽에서만 변경이 확인되고 그 값을 client쪽에서는 확인할 수 없다. 그에 반해 <code>Replicate</code>되는 변수는 서버쪽에서 변경하고 그 이후에 서버와 연결된 모든 client들에게 변경 사실을 알려주므로 client에서도 <code>Replicate</code>된 값을 확인할 수 있다. </p>
<h4 id="variable-replication">Variable Replication</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/8b3c84ec-99b6-4842-951b-8bca8e0a3051/image.gif" alt=""></p>
<p>다음은 <code>Network Relevancy</code>에 대한 데모였다. Map에서는 총 4가지의 상황을 두었지만, 가장 중요한 2가지 상황(Part III와 Part IV)에 대한 gif를 땄다. </p>
<p><strong>Part III은 플레이어가 chest를 열고 다른 플레이어가 chest의 Relevancy 반경(gif상에서는 파란색 테두리)에 들어오는 순간 chest가 열리는 VFX+변한 상자의 외형을 동시에 확인</strong>할 수 있다. 이는 <code>RepNotify</code>로 구현했는데, 상자의 열림/닫힘에 대한 bool 변수를 Replication 변수로 두었고, chest가 열리는 VFX의 실행을 해당 변수의 <code>RepNotify</code> 함수에 넣었다. 플레이어가 chest를 열면 bool 변수가 변하고, 다른 플레이어가 반경 안으로 들어오는 순간 <code>RepNotify</code> 함수가 실행되는 것이다. </p>
<h4 id="network-relevancypart-iii---보는-순서오른쪽-화면---왼쪽-화면">Network Relevancy(Part III) - 보는 순서(오른쪽 화면 -&gt; 왼쪽 화면)</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/bd32eb86-b942-4e9e-8835-5119fe692f6f/image.gif" alt=""></p>
<p><strong>Part IV에서는 플레이어가 chest를 열고 다른 플레이어가 chest의 Relevancy 반경(gif상에서는 파란색 테두리)에 들어오면 chest가 열리는 VFX 없이 변한 상자의 외형만 확인</strong>할 수 있다. 여기서는 <code>Multicast</code> 함수와 chset의 열림/닫힘에 해당하는 Replication 변수를 사용했다. <code>Multicast</code> 함수 안에는 chest가 열리는 VFX가 들어있어서 상자가 열릴 때 server가 자신과 접속한 모든 플레이어 중 Relevancy 반경 안의 플레이어들에게만 VFX를 보여준다. 따라서 나중에 상자 근처에 온 플레이어는 VFX는 보지 못하고 Replication 된 변수를 통해 상자가 열리는 모습만 보게 된다. </p>
<h4 id="network-relevancypart-iv---보는-순서오른쪽-화면---왼쪽-화면">Network Relevancy(Part IV) - 보는 순서(오른쪽 화면 -&gt; 왼쪽 화면)</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/345cbdbe-e5bf-44af-a7ea-296737913d7a/image.gif" alt=""></p>
<hr>
<h3 id="blueprint">Blueprint</h3>
<p><code>Blueprint</code>가 워낙 방대한 분량이라서 그런지 많은 데모들이 있었는데, 그 중에서 가장 재밌었고 유용했던 2가지 데모를 찍었다. </p>
<p>하나는 <code>Blueprint Interface</code>에 관한 것이다. 게임 프로젝트를 처음 시작할때 <code>Interface</code>에 중요하다는 사실은 알았지만 도대체 이걸 어디에 어떻게 적용할지가 굉장히 추상적이었다. 지금에서야 좀 이해가 되는데, 다음 데모 예시를 보면 이해에 더욱 도움될 것이라 생각한다. </p>
<p>데모에서 보이는 3가지 구체는 생김새는 다르지만, 불과 물에 반응한다는 공통점을 가지고 있다. 여기서, 이 <strong>공통점</strong>을 바로 <code>Interface</code>로 만들 수 있다. 이를 <code>Interface</code>로 만들고 3개의 구 클래스가 이를 상속하게 된다면, 구 클래스들은 불과 물에 반응하지만 각각의 상호작용 효과는 개별적으로 구현하는 것이다. 데모와 같이 말이다.</p>
<p><strong>쇠 구슬</strong> : 빨갛게 표면이 달아오름(<strong>불 쐈을때</strong>), 표면이 반짝반짝거림(<strong>물 쐈을때</strong>)
<strong>나무 구슬</strong> : 표면이 불에 탐(<strong>불 쐈을때</strong>), 표면이 탄 목재로 변함(<strong>물 쐈을때</strong>)
<strong>얼음 구슬</strong> : 점점 작아짐(<strong>불 쐈을때</strong>), 점점 작아짐(<strong>물 쐈을때</strong>)</p>
<p>좀더 확장성 있게 간다면 <strong>Interactable Interface</strong>라는 것을 만들어서 사용자와 상호작용하는 모든 클래스가 이를 상속하게 만들 수도 있는데, 다음에 기회가 된다면 시도해 보도록 하자.</p>
<h4 id="blueprint-interface">Blueprint Interface</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/524e5856-f376-4a70-86e4-b7c4dcfc2f31/image.gif" alt=""></p>
<p>다음 Blueprint 데모는 <code>Construction script</code>이다. <strong>Construction Script는 엑터가 월드에 추가되거나 변경될 때 자동 실행되는 초기화 코드 블록</strong>이다. 언리얼의 <strong>Actor Lifecycle</strong> 관점에서 보자면, <code>Construction Script</code>는 게임 실행 전의 &quot;설정&quot; 역할만 수행하고, <code>BeginPlay</code> 이후에는 실행되지 않는다. 변수에 따라서 Actor의 외형이 변하는데 이를 게임 시작전에 레벨에서 바로바로 확인할 경우가 있으면 <code>Constrcution Script</code>(C++에서는 OnConstruction을 오버라이딩해서), 안에 해당 로직을 집어 넣자.  </p>
<h4 id="blueprint-construction-script">Blueprint Construction Script</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/d295d1a3-0596-4b67-bdba-18ad1a729f9e/image.gif" alt=""></p>
<h3 id="귀여웠던-미니-게임들">귀여웠던 미니 게임들</h3>
<h4 id="blueprint-미니-게임-1">Blueprint 미니 게임 1</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/93cd23b5-80c7-4e00-aa1d-2b905901c32c/image.gif" alt=""></p>
<h4 id="blueprint-미니-게임-2">Blueprint 미니 게임 2</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/8863b4a7-3dce-4357-ac26-2d5dc03b2a84/image.gif" alt=""></p>
<hr>
<h3 id="nanite">Nanite</h3>
<p><code>Nanite</code>는 언리얼 엔진의 가상화된 폴리곤 렌더링 기술이다. 수십억 개의 폴리곤을 가진 모델을 실시간으로 렌더링하여 메시를 보여준다. 기존에는 LOD마다 거기에 해당하는 메시를 만들어서 거리에 따라서 메시를 바꿔치기 하는 방법을 사용했다면, <strong>나나이트는 LOD를 자동으로 최적화해서 멀리 있거나 작게 보이는 오브젝트는 자동으로 폴리곤 감소</strong>시킨다! 이렇게 되면, GPU적으로 엄청나게 자원 소모를 줄일 수 있다고 한다. </p>
<p>밑의 예시에서 플레이어가 향하는 기둥을 자세히 봐보자. 거리가 가까워질수록 LOD값은 높아져서 개별 폴리곤 수는 작아지고 총 폴리곤의 수는 증가하는 것을 볼 수 있다. </p>
<h4 id="viewmode를-nanite로-설정">Viewmode를 Nanite로 설정</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/f15cb17b-d8fb-4631-9ee5-a14d7351cb18/image.gif" alt=""></p>
<h4 id="55서부터-skeletal-mesh에도-nanite가-적용된다">5.5서부터 Skeletal Mesh에도 Nanite가 적용된다!</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/3bba8286-5696-477f-8b45-20883367507e/image.gif" alt=""></p>
<hr>
<h3 id="시뮬레이션">시뮬레이션</h3>
<p>내가 아는 언리얼에서 물리 시뮬레이션은 Simulate Physic과 Collision에서의 Query and Physics 밖에 없다. Fluid Simulation에 대한 데모가 있길래 신기해서 촬영해봤다. 조그만 입자들에 물리를 줌과 동시에 어떻게 연산 최적화가 가능지도 궁금하고 자연스러운 물 텍스쳐를 렌더링하는 방법도 궁금하다.</p>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/8788f7f8-584f-4b1e-beaf-12f102a5f03e/image.gif" alt=""></p>
<hr>
<h3 id="2d-sprite">2D Sprite</h3>
<p>예전의 언리얼 홍보 영상중에서 가상의 오락실을 만든게 있었는데, 오락실 안의 각각의 오락기에서는 서로 다른 미니 게임들이 동영상처럼 플레이되고 있었다. 나중에 그 홍보 영상을 만드신 Ed Bernett이라는 분께서 오락기 위의 영상은 <code>Flipbook</code>을 통해서 만들었다고 해서 인상 깊게 기억이 있다. 그런데 이번에, <code>UE ContentExamples</code> 2D Sprite 부분을 보다가 <code>Flipbook</code> 관련 데모가 나와서 가져와봤다. </p>
<p>설명을 보면, <code>Flipbook</code>을 만드는 2가지 방법이 있는데 하나는 단일 texture에서 frame animation을 추출하는 방법이고 다른 하나는 같은 애니메이션을 가지는 &quot;Frame Run&quot;들을 통해서 만든다고 적혀있다. </p>
<p>이해가 잘 안되서 좀 쉬운 설명을 찾아봤다. <code>Flipbook</code>은 여러 개의 2D 스프라이트(정적인 이미지)를 시간 순서대로 순차적으로 보여주어 애니메이션을 구현하는 시스템이라고 한다. 언리얼 엔진에서는 Paper2D 시스템을 통해 <code>Flipbook</code> 애니메이션을 만들며, 각 스프라이트를 설정된 시간 간격으로 반복 재생하거나 한 번만 실행하여 2D 캐릭터나 오브젝트의 애니메이션을 제어한다고 한다. GIF가 작동하는 방식과 비슷한 것 같다.</p>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/1cf74396-3545-46de-b6a5-ed6d626404a8/image.gif" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[UE ContentExamples #1]]></title>
            <link>https://velog.io/@ryan_ur/UE-ContentExamples-1</link>
            <guid>https://velog.io/@ryan_ur/UE-ContentExamples-1</guid>
            <pubDate>Sun, 16 Feb 2025 10:58:38 GMT</pubDate>
            <description><![CDATA[<p><strong>Unreal Global Game Fellowship</strong> 을 하면서 에픽 게임즈 멘토 분께 <code>UE ContentExamples</code> 프로젝트를 한번 봐보라는 조언을 얻었다. 시간이 조금 지나긴 했지만, 오늘 개인 프로젝트에 활용할 에셋을 얻을겸 어떤 재밌는 것들이 있을까 아침에 가벼운 마음에 이 프로젝트를 열었는데 벌써 밤이 되어 버렸다:D </p>
<p><code>UE ContentExamples</code>에는 차례대로 AI, Animation, Audio, Blueprint, Geometry, Level, Lighting, Material, Misc, Physics, UI, VFX에 대한 섹션이 있다. 각 섹션을 들어가게 되면 하위 Map들이 또 존재하는데, 각 Map에서 해당하는 주제에 대한 데모를 즐길 수 있다. </p>
<p>프로젝트를 둘러보다가 혼자서 아카이빙도 할겸, 내가 써볼만한 것들이나 재밌었던 섹션들을 따왔는데 이번 포스팅에서는 애니메이션에 대해서 기록을 해보려고 한다. </p>
<hr>
<h3 id="root-motion">Root Motion</h3>
<p>애니메이션 에셋 중에서는 캐릭터의 위치 자체가 변하는 것들이 있다. 예를 들어 사람 형태의 애니메이션이면 데미지를 입고 뒷걸음치는 모션이 있을 수 있다. 문제는 이런 애니메이션 에셋을 다른 애니메이션 에셋과 똑같이 적용해버리면 뒷걸음치는 모션이 끝나게 될때 그 모션이 시작하기 전 위치로 순간이동을 하게 된다. </p>
<p>이는 <code>Root Motion</code>을 사용하여 해결할 수 있다. <code>Root Motion</code>은 <code>Skeletal Mesh</code>의 최상위 본에 해당하는 Root bone의 이동 데이터를 애니메이션 자체에 포함시켜, 애니메이션이 재생될 때 해당 이동이 캐릭터에 직접 반영되도록 하는 방식이다. 다시 말하면, 보통 애니메이션을 재생할 때 루트 본이 이동하는 정보를 게임 내에서 따로 해석하게 되는데, <code>Root Motion</code>은 애니메이션 자체가 캐릭터의 이동을 결정하도록 설정하게 하는 것이다.</p>
<h4 id="root-motion-1">Root Motion 1</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/68cfcdb2-e418-4898-81ee-47d78ac473a2/image.gif" alt=""></p>
<h4 id="root-motion-2">Root Motion 2</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/14e8f3a5-2ae5-4c91-b25d-bc81c41b2c92/image.gif" alt=""></p>
<h3 id="additive-animation">Additive animation</h3>
<p><code>Additive animation</code>은 단어에서 큰 힌트를 찾을 수 있는데, <strong>Additive(더하다)</strong>의 말처럼 어떤 애니메이션에 어떤 애니메이션을 더한다고 볼 수 있다. 여기서 더해지는 애니메이션은 Base Pose에 해당하는 기준 애니메이션이고 더하는 애니메이션이 <code>Additive animation</code>을 할 에셋이다.</p>
<p><code>Additive animation</code>은 기존 애니메이션(Base Pose)과의 차이를 계산하여 추가적인 변화를 더하는 방식이다. 예를 들어, Base pose로 걷기 애니메이션을 두고 그 위에 Additive pose로 상체 흔들기 애니메이션을 더하면, 걷는 동안 상체만 따로 움직이게 할 수 있다. <code>Additive animation</code>에서 주의할 점은 두 애니메이션의 차이를 계산하는 특성상, 더해지는 애니메이션이 base pose 애니메이션과 상이한 차이가 있으면 안된다.(예를 들어 가만히 서있는 Idle pose에 팔벌려뛰기를 additive하지 말자!) </p>
<p>난 처음에 <code>Layered blend per bone</code>와 <code>Additive animation</code>가 매우 헷갈렸는데, <code>Layered blend per bone</code>은 애니메이션을 특정 Bone 단위로 블렌딩하는 기능으로, 특정 Bone을 기준으로 각 부위별로 다른 애니메이션(서로 아예 개별적인)을 재생한다고 생각하면 된다. 이에 반해 <code>Additive animation</code>는 base pose 위에 살짝 다른 효과를 얹어주는 것이다. </p>
<p><code>Layered blend per bone</code>과 <code>Additive animation</code>을 함께 활용할 수도 있다. 예를 들어, spine_1(우리의 단골 bone)으로 상/하체 애니메이션을 분리하고 상체 공격 애니메이션을 적용하면서 Additive 방식으로 얼굴 표정을 추가해 볼 수 있다. </p>
<h4 id="additive-animation-1">Additive animation</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/e447cbf2-5bb8-4380-ba0e-a5c5f84e026b/image.gif" alt=""></p>
<h3 id="layer--montage">Layer + Montage</h3>
<p><code>Animation Montage</code>는 우리가 많이 써봐서 익숙할텐데, 위에서 봤던 <code>Layered blend per bone</code>을 사용해서 특정 본을 기준으로 서로 다른 애니메이션이 재생될 영역을 만들고 원하는 영역에만 <code>Animation montage</code>를 재생할 수 있다. </p>
<h4 id="layered-montages">Layered Montages</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/15a4a33d-1e4f-4e89-96a5-667534b8b1c9/image.gif" alt=""></p>
<p>위 예시에서처럼 장전에 해당하는 input을 눌렀을때 캐릭터가 그냥 총 장전 <code>Animation montage</code>을 틀어버리면 가만히 멈춘채 장전(원래 장전 animation 에셋)이 재생되게 될 것이다. 하지만, 캐릭터가 움직이고 있을때에도 유연하게 총 장전 애니메이션이 실행되어야 하므로 하체는 움직이게 하고 상체에만 총 장전 <code>Animation montage</code>를 적용하므로서 자연스러운 장전 애니메이션을 만들 수 있다. </p>
<hr>
<h3 id="animation-retargeting">Animation Retargeting</h3>
<p>언리얼 5.4부터 애니메이션 리타겟팅이 매우 쉬워졌다. 지금까지 언리얼의 기본 마네킨과 Skeleton이 다른 캐릭터를 프로젝트에 넣어본 경험이 없어서 애니메이션 리타겟팅은 아주 쉽게 했었다. 하지만, 추후에는 사람뿐만이 아니라 다양한 캐릭터를 게임에 넣게 될 것인데 이때 사용할 <code>IK</code>와 <code>IK-Retargeter</code>에 대한 감을 여기서 잡아 볼 수 있을 것 같다.</p>
<h4 id="skeletal-mesh가-같은-skeleton인-경우">Skeletal Mesh가 같은 Skeleton인 경우</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/eb18e542-1139-4b63-90f1-d2d3a5f9f9c5/image.gif" alt=""></p>
<h4 id="skeletal-mesh가-다른-skeleton인-경우">Skeletal Mesh가 다른 Skeleton인 경우</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/9052a1b3-3e08-4272-bfc6-f616d0215a68/image.gif" alt=""></p>
<hr>
<h3 id="sequence">Sequence</h3>
<p>요즘 게임들을 하다보면 중간에 영상이 나오는데 가끔은 내가 영화를 보는 건지 헷갈릴 정도로 감동도 있고 영상미도 훌륭한 시퀀스들이 많다. 언리얼의 light와 post-process, 카메라 섹션을 가보면 게임의 미적 퀄리티를 확 끌어올려줄 테크닉들이 숨어 있다. </p>
<p><code>UE ContentExamples</code>의 <code>Sequence</code> 섹션에서는 별도의 Cine-camera로 찍은 시퀀스를 플레이하는 데모와, 내가 보고 있는 화면을 렌더링해주던 카메라가 sequence 카메라가 되어 보여주는 데모가 있었다.</p>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/39870254-e4ad-4ad0-9121-0fc5c45d8a9a/image.gif" alt=""></p>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/6d90dffb-6f1c-491c-9b89-5b42cd9d51fb/image.gif" alt=""></p>
<hr>
<h3 id="그외-다양한-애니메이션-데모들">그외 다양한 애니메이션 데모들</h3>
<h4 id="눈-부분에만-적용한-morphing">눈 부분에만 적용한 Morphing</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/4ed62206-efa0-4ee5-a36d-a06e00fcfe6d/image.gif" alt=""></p>
<h4 id="physics-animation-blending">Physics Animation Blending</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/e370e2da-e401-471f-a61d-b113f195fd7b/image.gif" alt=""></p>
<h4 id="다른-skeletal-mesh-같은-control-rig">다른 Skeletal Mesh, 같은 Control Rig</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/0eab02f2-a0f6-4edb-a593-62e915754e09/image.gif" alt=""></p>
<h4 id="multiple-layered-rigs">Multiple Layered Rigs</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/116e2bff-2c31-460b-99df-c867b809f413/image.gif" alt=""></p>
<h4 id="animator-kit-플러그인">Animator Kit 플러그인</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/a34c5b09-c0d3-4938-98ce-472c0afe2b28/image.gif" alt=""></p>
<h4 id="매번-변하는-공의-위치에-손이-따라가기">매번 변하는 공의 위치에 손이 따라가기</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/da33373f-26fe-4723-939e-496468f773a8/image.gif" alt=""></p>
<p>어떻게 한 것일까? FABRIK으로..?</p>
<h4 id="full-body-ik--body-mover">Full Body IK + Body Mover</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/13c8b234-29f0-4cb0-a6c6-076c7a6c8cb6/image.gif" alt=""></p>
<h4 id="physics-control-component-사용-정말-신기하고만">Physics Control Component 사용. 정말 신기하고만..</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/0df9b556-2b02-4cef-a1e8-5f435becfe08/image.gif" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[언리얼 RPC 톺아보기]]></title>
            <link>https://velog.io/@ryan_ur/%EC%96%B8%EB%A6%AC%EC%96%BC-RPC-%ED%86%BA%EC%95%84%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@ryan_ur/%EC%96%B8%EB%A6%AC%EC%96%BC-RPC-%ED%86%BA%EC%95%84%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Thu, 13 Feb 2025 03:33:10 GMT</pubDate>
            <description><![CDATA[<h2 id="언리얼-rpc-알아야-하는-이유">언리얼 RPC 알아야 하는 이유</h2>
<p>이번 포스팅에서는 <code>언리얼 RPC</code>에 대해서 알아보자. 멀티플레이 게임을 개발하기 위해서는 이 RPC에 대한 이해를 잘 하고 있어야지 다양한 상황에서 내가 구현하고 싶은 로직을 멀티플레이에 올바르게 올릴 수 있다. 거두절미하고 3개의 상황을 보면서 <code>언리얼 RPC</code>의 3가지 종류에 대해서 알아보자!</p>
<hr>
<h2 id="상황-a">상황 A</h2>
<p><strong>ʕتʔ Health라는 변수가 Replicate하기 ʕتʔ</strong></p>
<p>시작하기에 앞서 Health라는 변수를 <code>Replicate</code>해서 서버 및 이와 연결된 모든 클라이언트가 동일한 값이 되도록 만들어야 한다. 변수를 <code>Replicate</code>하는 방법은 다음과 같다. </p>
<ol>
<li>.h의 해당 변수 <code>UPROPERTY</code>안에 <code>Replicate</code> 처리</li>
<li>.h에 <code>GetLifetimeReplicatedProps</code> 선언</li>
<li>cpp에 <code>bReplicates</code>값 true로 설정</li>
<li>cpp에 <code>GetLigetimeReplicatedProps</code>에 변수 등록</li>
</ol>
<p>Health 변수를 CustomCharacter에 선언하고 이를 <code>Replicate</code> 해보자</p>
<h4 id="customcharacterh">CustomCharacter.h</h4>
<pre><code class="language-cpp">#pragma once

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

    UCLASS()
    class YOURGAME_API AMyCharacter : public ACharacter
    {
        GENERATED_BODY()

    public:
        AMyCharacter();

        // Step 1.
        // ReplicatedUsing은 해당 변수를 Replicated함과 동시에 변수가 변경되었을때 발동되는 콜백함수를 바인딩 할 수 있다.(여기서는 &quot;OnRep_Health&quot;에 해당)
        // 콜백 함수 없이 그냥 변수를 Replicate하고 싶으면 &quot;UPROPERTY(Replicated)&quot;를 써주면 된다.
        UPROPERTY(ReplicatedUsing = OnRep_Health)
        float Health;

    protected:
        UFUNCTION()
        void OnRep_Health();

        // Step 2.
        // Replicate한 변수를 등록하기 위해 &quot;GetLifetimeReplicatedProps&quot;라는 함수를 선언해주어야한다.
        virtual void GetLifetimeReplicatedProps(TArray&lt;FLifetimeProperty&gt;&amp; OutLifetimeProps) const override;
    };
</code></pre>
<h4 id="customcharactercpp">CustomCharacter.cpp</h4>
<pre><code class="language-cpp">#include &quot;MyCharacter.h&quot;
#include &quot;Net/UnrealNetwork.h&quot;

    AMyCharacter::AMyCharacter()
    {
        Health = 100.0f;

        // Step 3.
        // 이 클래스 자체가 Replicate 될것인지 설정
        bReplicates = true;
    }

    void AMyCharacter::OnRep_Health()
    {
        // 원한다면 콜백에 커스텀 로직 추가.
    }

    // Step 4.
    // .h에서 선언했던 &quot;GetLifetimeReplicatedProps&quot;에 Replicate할 변수 등록
    // DOREPLIFETIME 혹은 DOREPLIFETIME_CONDITION을 통해 변수의 Replicate 조건 또한 지정할 수 있다. 
    void AMyCharacter::GetLifetimeReplicatedProps(TArray&lt;FLifetimeProperty&gt;&amp; OutLifetimeProps) const
    {
        Super::GetLifetimeReplicatedProps(OutLifetimeProps);

        DOREPLIFETIME(AMyCharacter, Health);
    }
</code></pre>
<h4 id="replicate-변수-등록은-완료">Replicate 변수 등록은 완료!</h4>
<p>자 그러면 이제 Health 변수가 <code>Replicated</code> 처리 되었으므로 바로 Client에서 Health 변수를 변경하면 성공적으로 모두가 같은 <code>Replicated</code>된 변수값을 업데이트 받을 수 있을까? </p>
<p>사실, 이러면 안된다!! 서버에서 바꾼다면 값이 변경될 Client를 찾아서 Health 값을 업데이트 해주면 그만이지만, Client에서 Health 값을 바꾸려고 한다면 아무리 Health 변수가 <code>Replicate</code>되어 있다고 해도 이를 서버에서 바꿔 주어야 한다(언리얼의 <strong>Client-Server 구조</strong>를 잘 생각해보자). 따라서 Client에서 <code>ServerRPC</code>를 사용해서 서버가 Health 변수를 바꾸도록 만든다. <code>ServerRPC</code>를 사용하는 방법은 뒤에서 더 알아보기로 하자:)</p>
<hr>
<h2 id="상황-b">상황 B</h2>
<p><strong>ʕتʔ Client가 돌아다니는 맵에서 HealthPack을 줍고, Client의 HUD에는 체력 회복 효과 보여주기 ʕتʔ</strong></p>
<p>상황 B에서는 <code>ClientRPC</code>와 <code>SeverRPC</code>에 대한 개념이 모두 들어간다! 코드를 잘 읽어보면서 잘 따라와보자:D</p>
<p>구현해야할 상황에서 HealthPack이라는 새로운 아이템이 등장했으니 이에 대한 Class 생성이 필요할 것 같다. 기본적으로 캐릭터가 HealthPack을 먹어야 하므로 <strong>Collision Detection</strong>을 하는 로직이 필요할 것 같다. 그리고 마지막으로 Collision Detection에 걸린 캐릭터를 찾아서 <strong>Health 변경 로직</strong>을 수행하면 될 것 같다.</p>
<h4 id="healthpackcpp">HealthPack.cpp</h4>
<pre><code class="language-cpp">#include &quot;HealthPack.h&quot;
#include &quot;MyCharacter.h&quot;
#include &quot;Components/SphereComponent.h&quot;
#include &quot;GameFramework/Actor.h&quot;
#include &quot;Net/UnrealNetwork.h&quot;

    AHealthPack::AHealthPack()
    {
        // HealthPack을 먹었을때 회복될 양
        RestoreAmount = 30.0f;

        // Collision component 설정
        CollisionComponent = CreateDefaultSubobject&lt;USphereComponent&gt;(TEXT(&quot;CollisionComponent&quot;));
        CollisionComponent-&gt;InitSphereRadius(100.0f);sphere
        CollisionComponent-&gt;SetCollisionProfileName(TEXT(&quot;OverlapAllDynamic&quot;));
        CollisionComponent-&gt;OnComponentBeginOverlap.AddDynamic(this, &amp;AHealthPack::OnOverlapBegin);
        RootComponent = CollisionComponent;
    }

    void AHealthPack::OnOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, 
                                     UPrimitiveComponent* OtherComponent, int32 OtherBodyIndex, bool bFromSweep, 
                                     const FHitResult&amp; SweepResult)
    {
        if (ACharacter* Character = Cast&lt;ACharacter&gt;(OtherActor))
        {
            if (AMyCharacter* MyCharacter = Cast&lt;AMyCharacter&gt;(Character))
            {
                // MaxHealth보다 현재 Health가 낮을때에만 회복
                if (MyCharacter-&gt;Health &lt; MyCharacter-&gt;MaxHealth)
                {
                    // 회복 로직 수행
                    RestoreHealth(MyCharacter);
                }
            }
        }
    }

    // 회복 함수
    void AHealthPack::RestoreHealth(ACharacter* Character)
    {
        if (AMyCharacter* MyCharacter = Cast&lt;AMyCharacter&gt;(Character))
        {
            if (MyCharacter-&gt;HasAuthority())
            {
                // 서버에서 회복 로직 수행
                // MyCharacter에 존재하는 ServerRPC 부르기
                MyCharacter-&gt;ServerRestoreHealth(RestoreAmount);
            }
            else
            {
                UE_LOG(LogTemp, Warning, TEXT(&quot;RestoreHealth should only be called on the server!&quot;));
            }

            // 사용한 HealthPack은 이후 Destroy
            Destroy();
    }
</code></pre>
<p><strong>HealthPack.cpp</strong>의 코드는 다음과 같이 구현해 볼 수 있다. 여기서, Collision을 확인하는 부분인 <code>OnOverlapBegin</code> 부분에서 <code>HasAuthority</code>를 통해 Server만 <strong>Collision 체크</strong>를 하는 것을 주목하자(매번 헷갈리지만 <code>OnOverlapBegin</code>에서 Collision에 걸린 actor의 명칭은 <strong>OtherActor</strong>이다). HealthPack 아이템은 서버를 포함한 모든 클라이언트의 월드에 존재하지만 이 <code>HasAuthority()</code> 조건을 주면 오직 서버에서만 접근할 수 있다. </p>
<p>자! 그러면 이제 <strong>서버에서 접근+오버랩된 Actor</strong>까지 알았으니깐 그다음 무엇을 해야할까? 일단 첫번째로 <strong><code>Replicated</code> 처리된 Health의 값을 변경</strong>하는 것이 있겠고, 다음으로는 <strong>HealthPack을 먹은 client HUD 화면에 변경된 Health 값을 업데이트</strong>하는 과정이 있을 것이다. </p>
<p><strong>첫번째</strong>로 <code>Replicated</code> 처리된 Health 값을 변경하는 과정에 대해 알아보자. 이미, Collision 체크를 서버쪽에서 하고 있기 때문에, 바로 OtherActor로 구한 Character를 캐스팅하여 Health 값을 더해주면 된다(따로 Health Component를 사용하지 않고 Character 클래스 안에 단순하게 health 값이 들어있다고 가정).</p>
<p><strong>다음으로,</strong> Client HUD를 업데이트 하는 과정에 대해 알아보자. HealthPack을 먹은 Client 화면에서만 변화가 일어나면 되기 때문에 서버는 <code>ClientRPC</code>를 통해 해당 Client에 접근해 HUD를 변경하면 된다. 하지만, 이보다 더 좋은 방법이 있는데 Health 값은 <code>Replicated</code>되어 있는 변수이므로 Health에 해당하는 <code>RepNotify</code> 함수 로직안에 HUD를 변경하는 로직을 넣는 것이다. <code>RepNotify</code> 함수는 코드상에 <code>OnRep_</code> prefix가 붙는 함수이다.</p>
<h4 id="customcharactercpp-1">CustomCharacter.cpp</h4>
<pre><code class="language-cpp">#include &quot;MyCharacter.h&quot;
#include &quot;Net/UnrealNetwork.h&quot;
#include &quot;GameFramework/PlayerController.h&quot;
#include &quot;YourGameHUD.h&quot; // Replace with your actual HUD class

    AMyCharacter::AMyCharacter()
    {
        Health = 100.0f;
        MaxHealth = 100.0f;

        bReplicates = true;
    }

    // Health가 바뀌면 불려지는 콜백 함수에 HUD 업데이트 로직 추가 
    void AMyCharacter::OnRep_Health()
    {
        if (APlayerController* PC = Cast&lt;APlayerController&gt;(GetController()))
        {
            // 서버에서는 HUD를 업데이트 할 필요가 없으니,
            // IsLocalController()를 확인해서 client의 HUD만 업데이트 되게 하자
            if (PC-&gt;IsLocalController())
            {
                // Widget은 PlayerController에서 접근한다는 사실 다시 한번 기억!
                AYourGameHUD* HUD = Cast&lt;AYourGameHUD&gt;(PC-&gt;GetHUD());
                if (HUD)
                {
                    HUD-&gt;UpdateHealth(Health);
                }
            }
        }
    }

    // HealthPack에서 불려지는 SercerRPC
    void AMyCharacter::ServerRestoreHealth_Implementation(float Amount)
    {
        Health = FMath::Clamp(Health + Amount, 0.0f, MaxHealth);

        UE_LOG(LogTemp, Log, TEXT(&quot;Health updated on server: %f&quot;), Health);
    }

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

        DOREPLIFETIME(AMyCharacter, Health);
    }

</code></pre>
<p>눈치챈 사람이 있을 수 있겠지만, 여기서 CustomCharacter.cpp에 존재하는 ServerRestoreHealth를 굳이 <code>ServerRPC</code>로 만들 필요가 없다. <code>ServerRPC</code>는 기본적으로 Client가 Server에 존재하는 함수를 부르는 것인데, 이미 <code>OnBeginOverlap</code>에서 <strong>Authority 체크</strong>로 Server임을 확인했기 때문이다. 이렇게 되면, 서버가 <code>ServerRPC</code>를 호출하는 꼴이 되어 버리는 것이다. </p>
<p>하지만, 나중에 HealthPack이 아닌 다른 방법으로 체력을 추가하는 경우가 생기고 그 과정을 Client에서 다루고 싶다면 만들어둔 ServerRestoreHealth를 client에서 호출해 요긴하게 사용할 수 있을 것이다. </p>
<hr>
<h2 id="상황-c">상황 C</h2>
<p>** ʕتʔ Client A가 Client B에게 총을 쐈다. 이 과정을 A와 B를 제외한 게임에 참여하고 있는 다른 클라이언트들에게도 동일하게 보여주려면? ʕتʔ**</p>
<p>이 짧은 과정 속에 Client A의 사격 애니메이션, 총기 VFX와 SFX가 실행될 것이고 마찬가지로 피격을 당하는 B 입장에서도 피격 애니메이션, 피격 VFX와 SFX가 실행된다. 그리고 이 과정은 Client A와 B를 제외한 다른 Client의 시선에서 모두 동일하게 보여주어져야 한다. </p>
<p>이건 어떻게 구현해야 할까? 우리가 배운 지식을 보면 <code>Replicate</code>될 변수들은 <code>ServerRPC</code>로 변경하고, 이 상황을 보는 Client들의 HUD변화가 일어나야 한다면 일일이 <code>ClientRPC</code> 하는 방법이 떠오를 수 있다. 하지만, 이는 우리가 아직 다루지 않은 하나의 <code>MulticastRPC</code>로 한번에 해결할 수 있다. </p>
<h4 id="weaponh">Weapon.h</h4>
<pre><code class="language-cpp">#pragma once

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

    UCLASS()
    class YOURGAME_API AWeapon : public AActor
    {
        GENERATED_BODY()

    public:
        AWeapon();

        // Client, Server가 둘다 부를 수 있는 총기 사격 함수
        void FireWeapon();

        // 사격 ServerRPC(Client -&gt; Server)
        UFUNCTION(Server, Reliable, WithValidation)
        void ServerFireWeapon();
        void ServerFireWeapon_Implementation();

        // 사격 MulticastRPC(Server -&gt; 자기 자신(server)를 포함한 연결된 모든 Client)
        UFUNCTION(NetMulticast, Reliable)
        void MulticastFireWeaponEffect(FVector HitLocation, ACharacter* TargetCharacter);
        void MulticastFireWeaponEffect_Implementation(FVector HitLocation, ACharacter* TargetCharacter);

    private:
        float DamageAmount = 20.0f;  // Amount of damage the weapon deals
    };
</code></pre>
<h4 id="weaponcpp">Weapon.cpp</h4>
<pre><code class="language-cpp">#include &quot;Weapon.h&quot;
#include &quot;Net/UnrealNetwork.h&quot;
#include &quot;Kismet/GameplayStatics.h&quot;
#include &quot;GameFramework/Character.h&quot;
#include &quot;MyCharacter.h&quot;  // Replace with your actual character class

    AWeapon::AWeapon()
    {
        bReplicates = true;
        bReplicateMovement = true;
    }

    void AWeapon::FireWeapon()
    {
        // Server에서 FireWeapon 불렀을때
        if (HasAuthority())
        {
            FVector ShotDirection = GetActorForwardVector();
            FVector ShotStart = GetActorLocation();
            FVector ShotEnd = ShotStart + ShotDirection * 1000.0f;
            FHitResult HitResult;
            FCollisionQueryParams CollisionParams;
            if (GetWorld()-&gt;LineTraceSingleByChannel(HitResult, ShotStart, ShotEnd, ECC_Visibility, CollisionParams))
            {
                if (ACharacter* HitCharacter = Cast&lt;ACharacter&gt;(HitResult.GetActor()))
                {
                        // ApplyDamage를 통해 데미지 가하기
                        UGameplayStatics::ApplyDamage(HitCharacter, DamageAmount, GetInstigatorController(), this, UDamageType::StaticClass());

                        // Multicast 함수로 server포함한 모든 client에게 사격효과 보여주기
                        MulticastFireWeaponEffect(HitResult.Location, TargetCharacter);
                    }
                }
            }
        }
        // Client에서 FireWeapon 불렀을때
        else
        {
            // ServerRPC 실행
            ServerFireWeapon();
        }
    }

    void AWeapon::ServerFireWeapon_Implementation()
    {
        FireWeapon();
    }

    void AWeapon::MulticastFireWeaponEffect_Implementation(FVector HitLocation, ACharacter* TargetCharacter)
    {

        // 모두가 볼 수 있는 총격 효과 로직 
        // VFX, SFX를 각각 하나씩 담아본다.

        // VFX
        if (ExplosionEffect)
        {
            UGameplayStatics::SpawnEmitterAtLocation(GetWorld(), ExplosionEffect, Location);
        }

        // SFX
        if (ExplosionSound)
        {
            UGameplayStatics::PlaySoundAtLocation(GetWorld(), ExplosionSound, Location);
        }


    }
</code></pre>
<h4 id="customcharacterh-1">CustomCharacter.h</h4>
<pre><code class="language-cpp">#pragma once

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

    UCLASS()
    class YOURGAME_API AMyCharacter : public ACharacter
    {
        GENERATED_BODY()

    public:
        AMyCharacter();

        // ApplyDamage가 불려지면 자동으로 호출되는 TakeDamage
        UFUNCTION()
        void TakeDamage(float DamageAmount);

        UPROPERTY(Replicated)
        float Health;

    private:
        const float MaxHealth = 100.0f;
    };
</code></pre>
<h4 id="customcharactercpp-2">CustomCharacter.cpp</h4>
<pre><code class="language-cpp">#include &quot;MyCharacter.h&quot;
#include &quot;Net/UnrealNetwork.h&quot;

    AMyCharacter::AMyCharacter()
    {
        Health = MaxHealth;
        bReplicates = true;
    }

    void AMyCharacter::TakeDamage(float DamageAmount)
    {
        Health -= DamageAmount;
        Health = FMath::Clamp(Health, 0.0f, MaxHealth);
    }

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

        DOREPLIFETIME(AMyCharacter, Health);
    }
</code></pre>
<p><strong>상황 B</strong>처럼 <strong>상황 C</strong>에서도 시작은 Client가 하는 경우에 <strong>ServerRPC -&gt; (ApplyDamage + MulticastRPC)</strong>와 함수의 실행 순서가 나오게 된다. 반대로, Server로부터 시작을 하게 된다면 바로 <strong>ApplyDamage + MulticastRPC</strong>를 실행하면 된다.</p>
<p><strong>상황 C</strong>에서는 <code>ApplyDamage</code>와 <code>TakeDamage</code>라는 새로운 2개의 함수들이 등장했다. 데미지는 거의 모든 게임에 다 들어가기 때문에 언리얼에서 특별히 관련 함수를 만들었다. ApplyDamage는 데미지를 가하는 쪽에서 부르는 함수인데, 안에 인자로 데미지를 입힐 상대와 가할 데미지 양 등을 넣는다. </p>
<p><code>TakeDamage</code>는 데미지가 입혀질 상대에서 불려지는 함수이고, <code>TakeDamage</code> 함수를 구현하고 있어야 한다. 이 함수는 <code>ApplyDamage</code>로 자신이 지목되면 자동으로 호출되는 함수이기 때문에 따로 부르지 않아도 된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Multiplayer용 Session 시스템 만들어 보기 #2]]></title>
            <link>https://velog.io/@ryan_ur/Multiplayer%EC%9A%A9-Session-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EB%A7%8C%EB%93%A4%EC%96%B4-%EB%B3%B4%EA%B8%B0-2</link>
            <guid>https://velog.io/@ryan_ur/Multiplayer%EC%9A%A9-Session-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EB%A7%8C%EB%93%A4%EC%96%B4-%EB%B3%B4%EA%B8%B0-2</guid>
            <pubDate>Tue, 11 Feb 2025 13:37:35 GMT</pubDate>
            <description><![CDATA[<p>Multiplayer용 Session 시스템에 대한 감을 잡기 위해서 <code>AdvancedSessionSteam</code> 플러그인을 활용해서 순수 BP로 세션 클론코딩을 진행해보았다.(<a href="https://velog.io/@ryan_ur/Multiplayer%EC%9A%A9-Session-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EB%A7%8C%EB%93%A4%EC%96%B4-%EB%B3%B4%EA%B8%B0-1">링크</a>) 이후 계획으로 UE에서 자체 개발한 <code>OnlineSubsystemSteam</code> 플러그인 사용해 <strong>C++로 개발</strong>을 스위칭 하려고 했고, 이번에 <strong>UE 5.5</strong> 버전으로 <strong>세션 생성, 세션 찾기, 세션 참가</strong>등에 대한 로직을 만들어 보았다.</p>
<hr>
<p>C++ 개발에 앞서 약간 약간 무서웠던 부분은 UE 5 버전 이후, 특히 5.3 버전서부터 <code>AdvancedSessionSteam</code> 플러그인에서 세션 참가가 되지 않는 에러가 났다. 정확한 이유는 모르겠지만, Engine.ini안의 변수를 빌드과정에 오버라이딩, UE의 SteamSocket 문제, clientID가 계속 불필요하게 증가하는 복합적인 이유가 작용했던 것 같다. </p>
<h4 id="에러-이슈들">에러 이슈들</h4>
<p><a href="https://forums.unrealengine.com/t/ue-5-1-steam-sockets-problem/696726/55">https://forums.unrealengine.com/t/ue-5-1-steam-sockets-problem/696726/55</a>
<a href="https://issues.unrealengine.com/issue/UE-177030">https://issues.unrealengine.com/issue/UE-177030</a>
<a href="https://issues.unrealengine.com/issue/UE-174140">https://issues.unrealengine.com/issue/UE-174140</a>
<a href="https://forums.unrealengine.com/t/ue-5-1-steam-sockets-problem/696726/31">https://forums.unrealengine.com/t/ue-5-1-steam-sockets-problem/696726/31</a></p>
<p>이를 해결하기 위해서 위 링크들과 언리얼 포럼들을 꼼꼼하게 보면서 <strong>Engine.ini 세팅</strong>과 CreateSession을 만들때 들어가는 <strong>FOnlineSessionSettings</strong>을 하나하나 변경하는 아주 난잡한 테스팅을 여러번 진행해야 했었다. Steam Multi-play 테스팅을 위해서는 2개의 개별 머신에 각각 패키징된 프로그램이 필요한데, 1번의 테스팅을 위해서 패키징-&gt;압축-&gt;전송-&gt;압축 해제 등의 과정을 수십번을 하면서(사실 이번 C++ Session도 마찬가지...) 없던 탈모가 생길 것 같은 느낌이었다. 다행히, 개발 도중 <a href="https://sandboxie-plus.com/">Sandboxie</a>라는 VM 프로그램을 알게 되어서 1개의 PC에 2개의 steam 계정을 키고 테스팅을 비교적 간편하게 할 수 있었다. </p>
<hr>
<h2 id="onlinesubsystemsteam-c-개발-큰-그림">OnlineSubsystemSteam C++ 개발 큰 그림</h2>
<p>내가 생각하기에 <code>OnlineSubsystemSteam</code>에서 가장 중요한 부분은 <strong>2가지</strong>이다. <strong>하나</strong>는 <code>Create Session</code>, <code>Find Session</code>, <code>Join Session</code> 처럼 Session의 lifecycle에 관여하는 주요 함수들을 사용하는 방법과 각각의 환경변수 세팅에 대한 지식이다. <strong>다른 하나</strong>는 <code>OnlineSubsystemSteam</code>에서 이미 만들어준 <strong>Delegate</strong>과 <strong>Delegate-List</strong>의 활용 방법이다. 전자에 대해서는 밑에서 상세하게 다룰 것이니 <code>OnlineSubsystemSteam</code>의 delegate에 대해서 잠깐 알아보자. </p>
<p><code>OnlineSubsystem</code>에서는 <strong>Session lifecycle</strong>의 주요 단계마다 관련 delegate를 만들어두었고 이들은 각 단계가 실행될때마다 트리거된다. 예를 들어서, <code>CreateSession</code> 함수가 불려지게 되면 세션을 만들게 되는데 이때 자동으로 <code>FOnCreateSessionCompleteDelegate</code>의 타입을 가지는 delegate가 불려지게 된다. 따라서 우리는 세션 생성이 성공적으로 완료되었는지 확인하고 싶으면 콜백 함수를 하나 만들어서 이 delegate에 바인딩 하면 된다.</p>
<h4 id="onlinesubsystem에서-제공하는-delegate들">OnlineSubsystem에서 제공하는 Delegate들</h4>
<pre><code class="language-cpp">    // 이외에도 더 있음..!
    FOnCreateSessionCompleteDelegate OnCreateSessionCompleteDelegate;
    FOnFindSessionsCompleteDelegate OnFindSessionsCompleteDelegate;
    FOnJoinSessionCompleteDelegate OnJoinSessionCompleteDelegate;</code></pre>
<p>이런 delegate들이 존재하는 이유는 간단하다. 각각의 세션 과정이 즉각적으로 일어나지 않기 때문이다. 예를 들어서 세션을 찾으려면 먼저 세션이 있어야 되는데 이때, 세션이 있는지 확인하는 방법을 위와 같은 delegate를 사용해서 편하게 체크할 수 있기 때문이다. </p>
<hr>
<h2 id="세션-생성">세션 생성</h2>
<p>이제, 대망의 세션 생성을 해보자. 세션 생성은 <code>OnlineSubsystem</code>을 사용하면 매우 단순하다. <code>IOnlineSubsystem</code>에서 static method <strong>Get</strong>으로 <code>Online subsystem</code>에 대한 포인터를 얻어 올 수 있고, 이 포인터로 <code>CreateSession</code>이라는 함수를 불러주면 된다. </p>
<p>세션 생성 설정들은 다음과 같다. 우리는 Steam을 통해 세션을 만들점, listen server로 호스팅을 할점 등등을 고려하면 다음과 같이 세션 설정을 해주면 된다. </p>
<p>만약 세션 생성자가 UE에서 제공하지는 않지만 다른 정보를 세션에 넣어주고 싶으면 <code>TSharedPtr&lt;FOnlineSessionSettings&gt;</code> 변수 타입인 세팅 객체에 Set을 사용하면 된다. UE 5로 넘어오면서 이를 template 함수로 만들어두었는데, <strong>key</strong>값으로만 FName을 넣고 <strong>value</strong>에 해당하는 2번째 인자로는 변수 타입을 신경쓰지 않고 넣어줘도 된다.</p>
<pre><code class="language-cpp">    // Create session settings
    // https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Plugins/OnlineSubsystem/FOnlineSessionSettings
    const TSharedPtr&lt;FOnlineSessionSettings&gt; SessionSettings = MakeShareable(new FOnlineSessionSettings());
    SessionSettings-&gt;bIsLANMatch = false;
    SessionSettings-&gt;NumPublicConnections = 4;
    SessionSettings-&gt;bAllowJoinInProgress = true;
    SessionSettings-&gt;bShouldAdvertise = true;
    SessionSettings-&gt;bUsesPresence = true;
    SessionSettings-&gt;bUseLobbiesIfAvailable = true;
    // Key에 해당하는 값이 Template Type!
    SessionSettings-&gt;Set(FName(&quot;Session Type&quot;), FString(&quot;For Testing&quot;), EOnlineDataAdvertisementType::ViaOnlineServiceAndPing);

    // SessionSettings.bIsDedicated = false;

    // Create the session
    if (!OnlineSessionInterface-&gt;CreateSession(0, DefaultSessionName, *SessionSettings))
    {
        GEngine-&gt;AddOnScreenDebugMessage(-1, 5, FColor::Red, &quot;Failed to create new session.&quot;);
    }</code></pre>
<p>한가지 착각하기 쉬운 점은 세션 생성과 맵 이동은 개별적인 과정이라는 것이다. 따라서, <code>FOnCreateSessionCompleteDelegate</code>에 해당되는 콜백 함수에 <code>ServerTravel</code> 로직을 따로 넣어준다. 필자는 에셋 경로를 바로 에디터에 넣는 것보다 BP상에서 에셋을 지정하는 것을 선호해서 이번에도 전환될 맵 에셋을 BP에서 지정하도록 했다. </p>
<pre><code class="language-cpp">    //BaseGameInstance.h
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Session&quot;)
    TSoftObjectPtr&lt;UWorld&gt; TraveledMap;</code></pre>
<pre><code class="language-cpp">    //BaseGameInstance.cpp
    UWorld* LoadedMap = TraveledMap.LoadSynchronous();
    ...
    GetWorld()-&gt;ServerTravel(TraveledMap.GetAssetName() + TEXT(&quot;?listen&quot;), true);</code></pre>
<h2 id="세션-검색">세션 검색</h2>
<p><code>FindSession</code>도 설정 부분이 어렵지 부르는 것 자체는 <code>OnlineSubsystem</code>에서 다 함수로 구현해 놓았다. 앞에서 언급을 못했는데, <code>Create Session</code>과 <code>Find Session</code>은 나중 인게임에서 UMG와 연동될 것이기 때문에 편의를 위해서 <code>BlueprintCallable</code> 함수로 만들었다. </p>
<h4 id="find-session-함수-및-설정-값">Find Session 함수 및 설정 값</h4>
<pre><code class="language-cpp">    void UBaseGameInstance::OnFindSessionButtonClicked()
    {
        //SessionSearchSettings는 reference로 넘겨져서 SearchResult가 담기기 때문에 member variable로 선언해주어야 함. 
        SessionSearchSettings = MakeShareable(new FOnlineSessionSearch); 
        SessionSearchSettings-&gt;bIsLanQuery = false;
        SessionSearchSettings-&gt;MaxSearchResults = 10000;
        //https://github.com/EpicGames/UnrealEngine/commit/50d88b68429872ee689edcd0b2d623c60153be91
        SessionSearchSettings-&gt;QuerySettings.Set(SEARCH_LOBBIES, true, EOnlineComparisonOp::Equals);

        // Deprecated 되었음!
        // SessionSearch-&gt;QuerySettings.Set(SEARCH_PRESENCE, true, EOnlineComparisonOp::Equals);

        OnlineSessionInterface-&gt;AddOnFindSessionsCompleteDelegate_Handle(OnFindSessionsCompleteDelegate);
        GEngine-&gt;AddOnScreenDebugMessage(-1, 15, FColor::Green, TEXT(&quot;Start to find session!&quot;));
        OnlineSessionInterface-&gt;FindSessions(0, SessionSearchSettings.ToSharedRef());

    }</code></pre>
<p>세션 검색에서 한가지 신박했던 점은 <code>FindSession</code> 함수의 2번째 인자로 들어가는 값을 레퍼런스로 받아서 만약 검색되는 세션값들이 있다면 받은 레퍼런스 값 안의 TArray에다가 그 값들을 저장한다는 것이었다. </p>
<p>앞에 <code>CreateSession</code>에서 사용자가 <strong>key-value</strong> 형태로 커스텀한 값을 넣을 수 있다고 했는데, 세션 검색에서 이 <strong>key-value</strong>값을 만족하는 결과만 필터링해서 확인할 수 있다. </p>
<p>또, 이전 UE 버전에서는 SessionSearch-&gt;QuerySettings.Set을 통해 <strong>SEARCH_PRESENCE</strong>를 해주어야 했지만 이제는 Deprecated 되어서 <strong>SEARCH_LOBBIES</strong>를 대신 사용하였다. </p>
<h4 id="다양한-searchsetting-설정-변수">다양한 SearchSetting 설정 변수</h4>
<pre><code class="language-cpp">    /** Search for presence sessions only (value is true/false) */
    UE_DEPRECATED(5.5, &quot;SEARCH_PRESENCE (\&quot;PRESENCESEARCH\&quot;) is deprecated and will soon stop being a valid UE-defined key. Please consult upgrade notes for more details&quot;)
    const FName SEARCH_PRESENCE = FName(TEXT(&quot;PRESENCESEARCH&quot;));

    /** Search for a match with min player availability (value is int) */
    #define SEARCH_MINSLOTSAVAILABLE FName(TEXT(&quot;MINSLOTSAVAILABLE&quot;))
    /** Exclude all matches where any unique ids in a given array are present (value is string of the form &quot;uniqueid1;uniqueid2;uniqueid3&quot;) */
    #define SEARCH_EXCLUDE_UNIQUEIDS FName(TEXT(&quot;EXCLUDEUNIQUEIDS&quot;))
    /** User ID to search for session of */
    #define SEARCH_USER FName(TEXT(&quot;SEARCHUSER&quot;))
    /** Keywords to match in session search */
    #define SEARCH_KEYWORDS FName(TEXT(&quot;SEARCHKEYWORDS&quot;))
    /** The matchmaking queue name to matchmake in, e.g. &quot;TeamDeathmatch&quot; (value is string) */
    #define SEARCH_MATCHMAKING_QUEUE FName(TEXT(&quot;MATCHMAKINGQUEUE&quot;))
    /** If set, use the named Xbox Live hopper to find a session via matchmaking (value is a string) */
    #define SEARCH_XBOX_LIVE_HOPPER_NAME UE_DEPRECATED_MACRO(5.4, &quot;SEARCH_XBOX_LIVE_HOPPER_NAME has been deprecated. Use SETTING_MATCHING_HOPPER instead.&quot;) SETTING_MATCHING_HOPPER
    /** Which session template from the service configuration to use.*/
    #define SEARCH_XBOX_LIVE_SESSION_TEMPLATE_NAME UE_DEPRECATED_MACRO(5.4, &quot;SEARCH_XBOX_LIVE_SESSION_TEMPLATE_NAME has been deprecated. Use SETTING_SESSION_TEMPLATE_NAME instead.&quot;)  SETTING_SESSION_TEMPLATE_NAME
    /** Selection method used to determine which match to join when multiple are returned (valid only on Switch) */
    #define SEARCH_SWITCH_SELECTION_METHOD FName(TEXT(&quot;SWITCHSELECTIONMETHOD&quot;))
    /** Whether to search for lobbies instead of sessions */
    #define SEARCH_LOBBIES FName(TEXT(&quot;LOBBYSEARCH&quot;))</code></pre>
<p>Session 주요 함수에 들어가는 세팅을 확인하기 위해서는 Unreal Document를 보는 것보다 코드에서 설정 주석을 읽는 것이 더 도움이 많이 되었다. 위는 <strong>SearchSetting 관련 코드</strong>인데 <code>OnlineSessionNames.h</code> -&gt; <code>findingsessionsetting</code>에서 볼 수 있다. </p>
<h2 id="세션-참가">세션 참가</h2>
<p>세션 참가는 세션 검색이 끝난 다음에 실행이 되어야 하므로, <code>FOnFindSessionsCompleteDelegate</code>를 통해 세션 검색이 끝난후 불렀다. 여기서도, 주의해야 할 점이 세션 검색 함수에서 레퍼런스로 받은 인자가 중간에 변경이 되는 것인지 이를 세션 참가 전에 다시 한번 설정해 주어야 했다.  </p>
<h4 id="fonfindsessionscompletedelegate과-바인딩한-함수-로직">FOnFindSessionsCompleteDelegate과 바인딩한 함수 로직</h4>
<pre><code class="language-cpp">
    if (SessionSearchSettings-&gt;SearchResults.Num() &lt;= 0)
    {
        GEngine-&gt;AddOnScreenDebugMessage(-1, 15, FColor::Red, FString::Printf(TEXT(&quot;0 Session found:(&quot;)));
    }

    for (auto FoundSearchResult : SessionSearchSettings-&gt;SearchResults)
    {
        FString Id = FoundSearchResult.GetSessionIdStr();
        FString User = FoundSearchResult.Session.OwningUserName;
        FString SessionTypeReturned;
        // Key에 해당하는 Value가 있으면 넘긴 2번에 reference parameter에 저장.
        FoundSearchResult.Session.SessionSettings.Get(FName(&quot;Session Type&quot;), SessionTypeReturned);

        GEngine-&gt;AddOnScreenDebugMessage(-1, 15, FColor::Green, FString::Printf(TEXT(&quot;Session : %s found %s with Session type %s&quot;), *Id, *User, *SessionTypeReturned));
    }

    if (SessionSearchSettings-&gt;SearchResults.Num() &gt; 0)
    {
        // 이유는 모르지만 setting이 바뀌어진다?!
        // JoinSession 전에 FindSession에서 찾은 Session의 SessionSetting 다시 한번 설정
        SessionSearchSettings-&gt;SearchResults[0].Session.SessionSettings.bUseLobbiesIfAvailable = true;
        SessionSearchSettings-&gt;SearchResults[0].Session.SessionSettings.bUsesPresence = true;

        OnlineSessionInterface-&gt;AddOnJoinSessionCompleteDelegate_Handle(OnJoinSessionCompleteDelegate);
        OnlineSessionInterface-&gt;JoinSession(0, TEXT(&quot;GameSession&quot;), SessionSearchSettings-&gt;SearchResults[0]);
    }</code></pre>
<p>세션을 참가하는 클라이언트는 <code>JoinSession</code> 이후, <code>ClientTravel</code>로 맵 이동을 하게 된다. <code>ClientTravel</code>을 하기 위해서 찾은 세션 정보의 IP값과 포트 번호를 알아내야 했다. </p>
<p><code>Seamless</code> 설정을 하면 클라이언트가 <code>JoinSession</code>을 한것 만으로도(<code>ClientTravel</code> 부를 필요 x) 맵 이동이 자연스럽게 되지만, 여러번 시도 끝에 아직 성공을 못하였다. 지금까지 시도해본 바로는 Gamemode에 Seamless 변수 True, 사용하는 맵의 GameMode 통일, 설정-&gt;Build시 포함되는 맵에 맵추가를 해보았다.</p>
<hr>
<h2 id="테스트">테스트</h2>
<p>테스트를 하고 많이 하다보니깐 중간에 웃겨서 스샷을 남겼는데 그 이후로도 체감상 2배의 테스트를 위해 패키징을 한 것 같다. </p>
<h4 id="지옥의-테스트-과정">지옥의 테스트 과정</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/a81f5c8d-a4c5-4b7c-aeb5-ae5ba112ada1/image.png" alt=""></p>
<p>결국 성공!!</p>
<h4 id="create-session">Create Session</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/954b41e3-4610-4fd2-90da-699c13457612/image.gif" alt=""></p>
<h4 id="finding-session--joining-session">Finding Session =&gt; Joining Session</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/7da2c614-bd64-4190-97ab-a06667dcee1f/image.gif" alt=""></p>
<hr>
<h2 id="해상도-설정">해상도 설정</h2>
<p>해상도 설정에서 마지막으로 기록하고 싶은 부분이 있다. 언리얼 프로젝트를 패키징하면 기본 해상도로 FullScreen이 선택되는데, 이게 마우스로 화면 크기를 바꿀 수 없는 FullScreen이다보니 다른 창을 함께 봐야하는 상황이 있을때 매우 불편하였다. </p>
<p>패키징을 했을때 기본 뷰모드를 <strong>FullScreen</strong>에서 <strong>Windowed</strong>로 바꾸는 방법은 다음과 같다. </p>
<h4 id="defaultgameusersettingsini">DefaultGameUserSettings.ini</h4>
<pre><code>[/Script/Engine.GameUserSettings]
ResolutionSizeX=1280
ResolutionSizeY=720
FullscreenMode=2</code></pre><p>Config 폴더에 들어가 <code>DefaultGameUserSettings.ini</code> 파일을 (없으면) 하나 만들고 다음과 같은 값을 넣자.</p>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/b326c36b-61b5-4181-a535-8ba6d982ea85/image.png" alt=""></p>
<p><code>DefaultGameUserSettings.ini</code>을 넣어도 UE가 패키징을 할때 이를 무시하므로 시작 맵의 <strong>Level Blueprint</strong>에 초기화 과정을 한번 더 넣어주었다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[NAT & Port Forwarding]]></title>
            <link>https://velog.io/@ryan_ur/NAT-Port-Forwarding</link>
            <guid>https://velog.io/@ryan_ur/NAT-Port-Forwarding</guid>
            <pubDate>Wed, 05 Feb 2025 12:39:27 GMT</pubDate>
            <description><![CDATA[<p>요즘도 계속 <strong>세션 플러그인</strong>을 개발하고 있다. 분명 로컬에서 테스트 할때는 잘되는데 이를 패키징해서 서로 다른 로컬 디바이스에서 연결을 하려고 하니 계속 실패한다:( </p>
<p>사실, 문제가 조금 특이한데 한 디바이스에서 세션을 공개로 만들면 다른 디바이스에서 이를 찾는 것은 가능한데 밑의 사진처럼 말도 안되는 ping이 찍히고 세션에 join하는 것이 안된다.</p>
<h4 id="ping이-9999ms라니">Ping이 9999ms라니...</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/63d68f00-a1fe-4e82-b427-c34ad073c9ea/image.png" alt=""></p>
<p>쉽게 해결할 수 있을 문제일 것이라 생각했는데, 생각보다 잘 풀리지 않았고 다른 사람들 또한 이 같은 문제에 봉착해서 무려 $130나 하는 유로 플러그인(<a href="https://www.fab.com/listings/b900b244-0ff6-49e3-8562-5fc630ba9515">링크</a>)을 사용하기도 하였다. </p>
<p>Epic Games 커뮤니티 답변들을 보니 이에 대한 해결과정을 깔끔하게 정리해 놓은 사람들도 없었고, 답변 내용들을 이해하려면 네트워크 지식과 EOS에 대한 지식이 좀 많이 선행되어야 한다는 느낌을 받았다.</p>
<h4 id="eos-steam-관련-커뮤니티-답변">EOS Steam 관련 커뮤니티 답변</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/bac73f5c-780e-437e-a13a-df4e82550b02/image.png" alt=""></p>
<p>따라서, 네트워크 관련 복습도 할겸 이번 글에서는 <code>NAT</code>와 <code>Port Forwarding</code>에 대해서 서술해보고자 한다! </p>
<hr>
<h2 id="nat">NAT</h2>
<p><code>NAT</code>은 라우터 안에 존재하고 하는 역할은 간단하다. 라우터 안쪽 망에 연결된 Private IP들을 라우터의 public IP로 바꾼다. IP에는 Ipv4와 Ipv6가 있는데, Ipv4로 전세계에 있는 디바이스들마다 개별 IP를 부여하는 것이 불가능해서 라우터만 ISP에서 할당받은 public IP 배정받고 그 아래의 친구들은 private IP를 사용하므로서 이 문제를 해결했다. 따라서, IPv6가 본격적으로 쓰이게 된다면 NAT랑 private IP 사용 없이 그냥 간단하게 모든 기기마다 public IP를 부여할 가능성도 있다. </p>
<p><code>NAT</code>은 이번 주제의 다른 토픽이기도한 <code>Port Forwarding</code>과 밀접한 관련이 있는데, 다음 두 상황을 보면서 왜 그런지 이해해보자. 참고로, <strong>상황 1</strong>은 <code>NAT</code>에 대해서, <strong>상황 2</strong>는 <code>NAT</code>과 <code>Port Forwarding</code>에 대한 예시이다.</p>
<h3 id="상황-1">상황 1</h3>
<p><strong>내부에서 먼저 외부로 요청을 보내는 상황</strong></p>
<ol>
<li><p>SourceIP(PrivateIP) + SourcePort =&gt; DestinationIP + DestinationPort(내부에서 외부로 요청 보내려고 준비)</p>
</li>
<li><p>SourcePublicIP(<code>NAT</code>이 라우터 IP로 변환) + ChangedSourcePort(포트 번호 또한 충돌을 위해 <code>NAT</code>이 변환) =&gt; DestinationIP + DestinationPort(내부의 요청이 NAT을 통과하면서 최종적으로 외부로 요청 보낼 준비 끝!)</p>
</li>
<li><p>2의 과정이 일어날때 <code>NAT</code> 테이블에 기록해둠</p>
</li>
<li><p>DestinationIP + DestinationPort =&gt; SourcePublicIP + ChangedSourcePort (요청받은 외부가 요청한 내부로 다시 응답)</p>
</li>
<li><p><code>NAT</code> 테이블에 ChangedSourcePort가 어떤 SourcePort인지 적혀있으므로 올바른 SourcePort에 해당하는 프로세스로 연결</p>
</li>
</ol>
<hr>
<h2 id="port-forwarding">Port Forwarding</h2>
<h3 id="상황-2">상황 2</h3>
<p><strong>외부에서 먼저 내부로 요청을 보내는 상황</strong></p>
<ol>
<li>마찬가지로 SourceIP + SourcePort =&gt; DestinationIP(라우터 IP) + DestinationPort</li>
</ol>
<p>근데 여기서, 요청을 보내는 외부의 입장에서는 라우터 내부의 포트가 뭘지 알고 &quot;DestinationPort&quot;을 정하는 것일까?</p>
<p>사실 답은 &quot;모른다&quot;이다. 라우터의 <code>NAT</code>도 이 요청이 들어온 DestionationPort이 라우터와 연결된 어떤 프로세스에 전달할지 모른다.</p>
<p>그래서 <code>Port Forwarding</code>이라는 개념이 들어온 것이다. <strong>Forward</strong>라는 단어는 여기서 <strong>전달</strong>을 의미한다. <code>Port Forwarding</code>이란 <code>NAT</code>에 어떤 테이블을 하나 만드는 것인데 외부에서 요청이 들어온 DestionationPort가 어떤 라우터 내부의 포트(Broadcast처럼 1개의 DestionationPort당 여러개 포트가 매핑될수도 있음)에 가야하는지 사전에 미리 작성해 두는 것이다. </p>
<p>이제 <code>Port Forwarding</code>을 적용해서 상황 2를 다시 한번 보자.</p>
<p><strong>외부에서 먼저 내부로 요청을 보내는 상황</strong></p>
<ol>
<li><p>사전에 라우터에 <code>Port Forwarding</code> 작업을 한다.</p>
</li>
<li><p>SourceIP + SourcePort =&gt; DestinationIP(라우터 IP) + DestinationPort(외부에서 내부로 요청 보내기) </p>
</li>
<li><p>라우터는 요청을 받으면 Port-Forwarding 테이블을 보면서 외부에서 보낸 요청의 포트번호가 라우터 내부의 어떤 포트번호로 요청이 들어가는지 알 수 있다.</p>
</li>
</ol>
<h4 id="포트-포워딩-테이블">포트 포워딩 테이블</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/cb93cf38-68e9-4d71-a687-401beba0c278/image.png" alt=""></p>
<p>cmd에 <strong>ipconfig</strong>를 치면 IP와 관련한 다양한 네트워크 속성들을 확인할 수 있다. 여기서 게이트웨이에 해당하는 것이 라우터 관련 값인데 이를 복사해서 인터넷 브라우저에 넣으면 자신이 사용하고 있는 라우터 환경설정에 들어가진다. 필자는 SK 브로드밴드를 사용하는데 위 사진이 SK 브로드밴드 라우터 설정 화면이다. </p>
<p>저번 글로벌 게임잼에서 원격접속을 하기 위해 <strong>RDP</strong> 작업(<a href="https://velog.io/@ryan_ur/Mac%EC%9C%BC%EB%A1%9C-Window-%EC%9B%90%EA%B2%A9%EC%A0%91%EC%86%8D-%ED%95%B4%EB%B3%B4%EA%B8%B0">링크</a>)를 하려고 3389 포트로 <code>Port Forwarding</code> 하였다. 앞으로 EOS-Steam도 <code>Port Forwarding</code>을 해야한다면 이 테이블에 추가로 필드를 넣어야 한다.</p>
<hr>
<h2 id="방화벽">방화벽</h2>
<p>가끔 <code>Port Forwarding</code>을 이야기하면서 방화벽이 같이 등장하기도 한다. </p>
<p>먼저 방화벽의 종류에 대해서 알아보자. 방화벽은 크게 2가지 종류가 있는데, 하나는 <strong>Network-based</strong>으로서 보통 라우터 안에 존재하고, public network와 private network 사이에 패킷들을 검사한다. <strong>Host-based</strong> 방화벽도 있는데 이들은 local machine안에 위치한다. Window Defender Firewall 같은 것들이 Host-based 방화벽이다.</p>
<p><code>Port Forwarding</code>에서 등장하는 방화벽은 <strong>Network-based</strong> 방화벽이다. 이 친구는 자기만의 테이블이 있어서 외부의 이상한 port에서 들어오는 패킷은 자동으로 drop 시킨다. 이때, <code>Port Forwarding</code>을 하면 해당 포트는 방화벽에 의해 제거될 대상에서 벗어날 수 있는 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Multiplayer용 Session 시스템 만들어 보기 #1]]></title>
            <link>https://velog.io/@ryan_ur/Multiplayer%EC%9A%A9-Session-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EB%A7%8C%EB%93%A4%EC%96%B4-%EB%B3%B4%EA%B8%B0-1</link>
            <guid>https://velog.io/@ryan_ur/Multiplayer%EC%9A%A9-Session-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EB%A7%8C%EB%93%A4%EC%96%B4-%EB%B3%B4%EA%B8%B0-1</guid>
            <pubDate>Mon, 03 Feb 2025 09:43:36 GMT</pubDate>
            <description><![CDATA[<p>이번에 새로운 멀티플레이 포트폴리오 작업 계획을 세울때 어차피 세션에 대한 개념이 들어가고 이 세션 시스템을 한번 만들어 놓으면 나중에 다른 멀티플레이 프로젝트를 할때도 많이 사용할 것 같아 이를 그냥 플러그인으로 만들어 보려고 했다.</p>
<p>따라서 현재 언리얼의 세션이 어떻게 돌아가는지, FAB에는 어떤 세션 플러그인들이 존재하는지, 내가 더 개선해서 플러그인으로 만들만한 부분들이 있는지 등을 먼저 조사해보았다. </p>
<hr>
<h2 id="언리얼-세션관련-주요-항목들-c">언리얼 세션관련 주요 항목들 (C++)</h2>
<h3 id="ionlinesession">IOnlineSession</h3>
<p><code>IOnlineSession</code>은 언리얼이 세션 관련 중요 함수들을 모아놓은 인터페이스이다. 대표적으로 Session Create, Session Find, Session Join, Session Destroy, Session Start와 같은 함수들을 제공한다. 또한, 각각의 함수들이 불려졌을때 트리거되는 Delegate(i.e. FOnCreateSessionCompleteDelegate)들 또한 제공하고 있다. </p>
<h3 id="fonlinesessionsettings">FOnlineSessionSettings</h3>
<p>보통 언리얼에서 &#39;F-&#39; prefix가 붙으면 구조체를 말하는데 <code>FOnlineSessionSettings</code> 그냥 일반 클래스여서 약간 의아했다. 이 클래스에서는 Session 관련 함수를 부를때 추가적인 설정을 더할 수 있다. 예를 들어서 <code>IOnlineSession</code>에 존재하는 CreateSession은 단순하게 이렇게 생겼다. </p>
<pre><code class="language-cpp">SessionInterface-&gt;CreateSession(0, SessionName, SessionSettings);
</code></pre>
<p>위 코드에서 SessionSettings에 추가 정보를 입력함으로서 디테일하게 세션 관련 함수를 사용할 수 있다. </p>
<h4 id="대표적으로-많이-사용하는-추가-session-settings">대표적으로 많이 사용하는 추가 Session Settings</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/ec0dc49e-9643-4988-99ec-5af870cdf306/image.png" alt=""></p>
<h3 id="gamemode-gamestate-gameinstance">GameMode, GameState, GameInstance</h3>
<p>이 클래스들은 항상 중요한 친구들이지만 세션 작업시에도 이들이 어떻게 일을 하는지 잘 알고 있어야 한다. <code>GameMode</code>는 Multiplay 게임에서 사용자가 들어오고 나갈때 작동하는 PostLogin, Logout 함수들이 있다. <code>GameState</code>에는 Login을 완료한 연결된 플레이어의 PlayerState 배열을 들고 있어 현재 몇명이 server에 연결되어 있는지 확인할 수 있다. <code>GameInstance</code>는 Singleton 객체이면서 Multiplay 게임 도중 맵이 전환될때에도 죽지 않고 살아있다(다른 객체들은 server-travel이 일어나면 없어졌다가 다시 spawn된다)</p>
<hr>
<h2 id="advancedsessionplugins">AdvancedSessionPlugins</h2>
<p>인터넷 서칭을 해보니 Joshua란 사람이 이미 <code>FOnlineSessionSettings</code>의 세팅을 한꺼번에 포함해서 세션관련 함수들을 다룰 수 있는 BP 함수 라이브러리를 만들어 놓았다. 이에 관련해서 유튜브에 많은 튜토리얼 또한 존재했어서 이분이 만든 프로덕트를 먼저 사용해보기로 했다. </p>
<p><a href="https://github.com/mordentral/AdvancedSessionsPlugin">AdvancedSessionPlugins 깃헙 링크</a></p>
<h3 id="설치-방법">설치 방법</h3>
<ol>
<li>Github description에 들어가보면 ue4 전용이라고 하지만, 사이트에서 binary 섹션에 들어가보면 5.5 build까지 되어 있다. 이를 다운로드 하자.</li>
<li>새로운 언리얼 프로젝트를 파고 안에다가 Plugins 폴더 생성. 다운로드 받은 항목에서 <code>AdvancedSessions</code>와 <code>AdvancedSteamSessions</code> 2개 파일 이동</li>
<li>.vs, DerivedDataCache, Intermediate, Saved, .vsconfig, .sln 지우고 uproject -&gt; regenerate files한다.</li>
<li>새롭게 생성된 .sln 파일에서 build</li>
<li>Unreal 파일(.uproject)에서 Plugins 탭에 들어가서 Advanced Sessions과 Advanced Steam Sessions 플러그인들이 잘 활성화되어 있는지 다시 한번 체크 + OnlineSubsystem 까지</li>
<li>OnlineSubsystem Steam을 사용하려면 DefaultEngine.ini에 추가적인 설정을 해야한다.(만약 이미 사용하고 있는 SteamDevAppId가 있다면 그 번호를 설정에 넣을것! <a href="https://dev.epicgames.com/documentation/ko-kr/unreal-engine/online-subsystem-steam-interface-in-unreal-engine">참고 링크</a>)</li>
<li>Steam을 백그라운드에서 실행하고 StandAlone 모드로 게임을 돌려봐서 Steam이 제대로 연결되었는지 확인 (이때는 다시 rebuild 할 필요는 없음)</li>
</ol>
<h3 id="일반-sessioncreate-vs-advancedsessionplugin의-sessioncreate">일반 SessionCreate VS AdvancedSessionPlugin의 SessionCreate</h3>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/70f227e0-0db5-4582-bdf5-984028e41afb/image.png" alt=""></p>
<p>왼쪽이 언리얼에서 기본적으로 제공하는 CreateSession BP 노드이고, 오른쪽이 AdvancedSEssionPlugin에서 만든 CreateSession BP 노드이다. <code>FOnlineSessionSettings</code>에서 제공하는 추가 세팅들을 그냥 인풋핀에 박아버린 것을 확인할 수 있다. </p>
<p>또, 여기서 하나의 중요한 킥이 있는데 바로 <code>Extra Settings</code> 인풋 핀이다. 여기서는 자기가 Session을 만들때 넣고 싶은 값을 Key-Value 형태로 만들어서 보관할 수 있다. 예를 들어서 세션의 이름, 세션의 제작자, 세션을 만든 위치 등 다양한 정보를 상황에 맞게 넣을 수 있다. </p>
<hr>
<h2 id="플러그인-테스트-해보기">플러그인 테스트 해보기</h2>
<p>여러 관련 유튜브 튜토리얼을 보면서 <strong>Pixel Helmet</strong>이라는 채널의 자료를 사용해서 플러그인 테스트를 진행해보았다. </p>
<p><a href="https://www.youtube.com/watch?v=PNWQQ5wwniA&amp;t=4s">테스트 프로젝트 진행(초기 버전)</a></p>
<p>기능은 아주 간단하다. 사용자는 <strong>세션을 만들거나</strong> 아니면 <strong>만들어진 세션 목록들을 보면서 자기가 세션에 참여</strong>할 수 있다. 세션을 만드는 창에서는 세션 이름, 최대 접속 플레이어 수, 나라, 맵 등을 설정해서 세션을 만들 수 있다. </p>
<p>만들어진 세션 목록들을 볼수있는 Session Browser에서는 Session을 만들때 넣었던 추가 항목들로 필터링해서 결과 값을 볼 수 있다. 여기서 디테일이 있는데, 처음 Session Browser에 진입하는 순간과 Refresh 버튼을 누르는 순간에만 생성된 모든 세션 결과를 가져와 저장한다. 이게 시간이 은근 많이 걸리는 작업임으로 필터링할때 매번 세션 결과를 가져오지 않게 했다.</p>
<h4 id="session-생성-창">Session 생성 창</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/c63484c1-296f-413a-b88c-7934ce03bd6d/image.gif" alt=""></p>
<h4 id="session-browser-들어가는-화면">Session Browser 들어가는 화면</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/28accc3b-014e-4e85-adb4-ed63c071bc21/image.gif" alt=""></p>
<h4 id="session-browser에서-필터링">Session Browser에서 필터링</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/cb7e4b64-f14c-4163-a1eb-626de431e328/image.gif" alt=""></p>
<h4 id="session-browser에서-session-join">Session Browser에서 Session Join</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/48a3af58-65d3-4f2e-8283-7899ecb43115/image.gif" alt=""></p>
<hr>
<h2 id="고전했던-부분">고전했던 부분</h2>
<p><strong>첫 번째</strong>로 고전했던 부분은 Client와 Server의 초기 화면을 맞추는 것이였다. <code>레벨 블루프린트</code>에서 시네마틱 카메라와 <code>Set View Target With Blend</code>를 사용해서 백그라운드에 고정된 영상을 실시간으로 틀어주는 것이 가능하다. 하지만, Server에서만 영상이 틀어지고 Client에서는 틀어지지 않는 문제가 발생했다.</p>
<p>이유를 찾아보니 레벨 블루프린트의 로직은 서버에서만 실행이 된다고 한다. 따라서 이를 해결하기 위해 <code>Set View Target With Blend</code> 관련 로직을 Client RPC로 만들어 Player Controller안에 위치 시켰다. 그리고, 레벨 블루프린트에서는 서버가 GameState에 접근해 연결된 모든 Player의 PlayerController의 Client RPC를 직접 실행시켜 주었다.(사실 여기서도 PlayerController가 로그인되기 전에 GameState를 먼저 접근해버려서 문제가 생겼는데 일단은 임시방편으로 delay를 사용해서 해결했다. PostLogin에 델리게이트를 연결하면 깔끔하게 해결할 수 있을 것 같긴 하다.) </p>
<h4 id="1번째-고전했던-문제-고치기-전-화면">1번째 고전했던 문제 고치기 전 화면</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/92a9cf46-5104-4a4d-947e-4a0b4b937ed4/image.png" alt=""></p>
<h4 id="1번째-고전했던-문제-고친-후-화면">1번째 고전했던 문제 고친 후 화면</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/5a7a9516-2a4c-4071-9229-6789c76e7014/image.png" alt=""></p>
<p><strong>두 번째</strong>로 고전했던 부분은 GameMode/GameModeBase와 GameState/GameStateBase의 관계였다. 둘다, ~Base가 붙지 않는 클래스가 더 확장된 기능을 제공한다. 여기서 GameMode를 상속한 클래스는 GameState를 상속한 클래스를 사용해야 되고 반대로, GameModeBase를 상속한 클래스는 GameStateBase를 상속한 클래스를 사용해야 한다. 이를 몰라서 많이 해맸다.</p>
<p><strong>세 번째</strong>로 고전했던 부분은 맵이였다. 플레이어가 Session Browser에서 선택한 세션과 연관된 맵으로 client travel하려면 맵에 대한 정보를 알아야 한다. 처음에는 언리얼 맵의 타입인 UWorld의 Soft Reference를 저장하는 것이 가능해서 Reference로 직접 맵을 열려고 했으나 이름만으로 열 수 있는 방법이 있다는 것을 알았다. </p>
<p>이름으로 맵을 잘 열다가 프로젝트를 패키징할때 맵 관련 오류가 나서보니 게임 프로젝트에 사용되는 맵을 따로 Project Setting에 아래와 같이 등록했었어야 했다.</p>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/14d7c4f2-72b9-4d93-9b7a-f9420c3fd59a/image.png" alt=""></p>
<hr>
<h2 id="추가-작업-계획">추가 작업 계획</h2>
<ul>
<li>지금은 Listen Server로 맵을 만들고 ServerTravel하는데 이를 Dedicated로 바꿔보기</li>
<li>Firewall의 문제인지 만든 프로젝트를 패키징해서 다른 머신과 로컬에서 접속해보면 세션을 찾지 못하는데 이 문제 해결해보기</li>
<li>서버 인스턴스가 ServerTravel하면 client 인스턴스들의 화면이 갑자기 새로고침이 되는데 이것의 원인 파악해보기</li>
</ul>
<hr>
<h2 id="reference">Reference</h2>
<p><a href="https://vreue4.com/advanced-sessions-plugin">https://vreue4.com/advanced-sessions-plugin</a>
<a href="https://github.com/mordentral/AdvancedSessionsPlugin">https://github.com/mordentral/AdvancedSessionsPlugin</a>
<a href="https://www.youtube.com/@PixelHelmet">https://www.youtube.com/@PixelHelmet</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[언리얼 에셋 라이센스에 대해 알아보자!]]></title>
            <link>https://velog.io/@ryan_ur/%EC%96%B8%EB%A6%AC%EC%96%BC-%EC%97%90%EC%85%8B-%EB%9D%BC%EC%9D%B4%EC%84%BC%EC%8A%A4%EC%97%90-%EB%8C%80%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90</link>
            <guid>https://velog.io/@ryan_ur/%EC%96%B8%EB%A6%AC%EC%96%BC-%EC%97%90%EC%85%8B-%EB%9D%BC%EC%9D%B4%EC%84%BC%EC%8A%A4%EC%97%90-%EB%8C%80%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90</guid>
            <pubDate>Thu, 23 Jan 2025 10:38:24 GMT</pubDate>
            <description><![CDATA[<p>이번에 만들 포트폴리오는 포트폴리오에 그치지 말고 itch.io 같은곳에 직접 배포를 해보기로 결심하였다. 이를 위해서 가장 먼저 세운 계획은 사용할 수 있는 에셋들의 목록을 보는 것이였다. 항상 프로토타입으로만 에셋을 사용했어서 라이센스에 대한 항목을 자세히 보지 않았는데, 이번에는 지금까지 받았던 에셋들을 모두 훑어보면서 각각의 라이센스들이 어떻게 구성되어 있는지 보았다. </p>
<p>그 결과 라이센스와 관련된 항목은 크게 <strong>License Terms</strong>와 <strong>License</strong>가 있었다. <strong>License Terms</strong>는 <code>Standard License</code>, <code>UE Marketplace</code>, <code>Creative Commons Attribution (CC BY 4.0)</code> 중 하나로 되어 있었고 <strong>License</strong>는 <code>Personal</code> 아니면 <code>Professional</code>으로 되어있었다.</p>
<hr>
<h2 id="license-terms">License Terms</h2>
<p>먼저, <strong>License Terms</strong>에 대해 알아보자. <strong>License Terms</strong>는 체감상 80% 이상이 <code>Standard License</code>였고 10% 정도가 <code>UE Marketplace</code> 나머지 10% 정도가 <code>Creative Commons Attribution (CC BY 4.0)</code>였다. </p>
<h4 id="licensetermsstandard-license">Licenseterms(Standard License)</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/2ae57ccf-dca9-4923-9cb7-74c08391fada/image.png" alt=""></p>
<h4 id="licensetermsue-marketplace">Licenseterms(UE Marketplace)</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/c8118b05-90bb-4cc9-b53c-6c93412d57df/image.png" alt=""></p>
<h4 id="licensetermscreative-commons-attribution-cc-by-40">Licenseterms(Creative Commons Attribution (CC BY 4.0))</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/ea8620e6-a46c-47a5-9f31-38a939282729/image.png" alt=""></p>
<p>먼저, <code>Standard License</code>에 대해서 알아보자. 이는 언리얼 마켓플레이스의 일반적인 라이센스이며, 대부분의 유료 또는 무료 에셋에 적용된다고 한다. <strong>사용 가능 범위</strong>는 언리얼 엔진을 사용한 프로젝트에만 가능하고, 에셋을 수정하거나 변형하여 사용가능하다고 한다. <strong>추가 조건</strong>으로는 에셋을 재판매하거나 다른 플렛폼에 배포할 수 없고, 수정한 에셋도 독립적인 판매는 불가능하다고 한다. </p>
<p>다음은 <code>UE Marketplace License</code>이다. <strong>사용 가능 범위</strong>랑 <strong>추가 조건</strong> 모두 <code>Standard License</code>와 매우 비슷한데, <code>UE Marketplace License</code>는 언리얼 마켓플레이스 외에서 구체적으로 언급되지 않은 조건에 따라 차이가 있을 수 있다고 한다. </p>
<p>마지막으로 <code>Creative Commons Attribution (CC BY 4.0)</code>이다. Creative Commons에서 제공하는 공개 라이센스이며 에셋 제작자가 이를 선택하면 언리얼 엔진 외에서도 사용할 수 있다. <strong>사용 가능 범위</strong>는 엔진 공류 상관 X, 상업적 사용 O,수정 후 재배포 O이다. <strong>추가 조건</strong>으로는 반드시 제작자의 이름과 원작의 출처를 명시해야 하고, 수정한 경우에도 원작자를 표시해야 한다. </p>
<p>위의 내용을 표로 정리하면 다음과 같다. 
<img src="https://velog.velcdn.com/images/ryan_ur/post/5aa4c4e2-66c3-41ad-9218-660398c94b5e/image.png" alt=""></p>
<h2 id="license">License</h2>
<p><strong>License Terms</strong>외에도 <strong>License</strong>라는 것도 존재했는데, 이는 구매자들로 하여금 <code>Personal</code>과 <code>Professional</code> 중 하나를 고르게 해야 했다.</p>
<p><code>Personal</code>은 개인 개발이나 소규모 제작사에게 해당되며 1.4억 이하의 영업이익 또는 12개월동안의 펀딩이 이 금액 이하로 되어야 한다.</p>
<p><code>Professional</code>은 1.4억 이상의 영업이익 또는 이 금액 이상으로 12개월동안의 펀딩이 진행된 스튜디오에 해당한다.</p>
<h4 id="에셋-제목-바로-밑에서-license-확인-가능">에셋 제목 바로 밑에서 License 확인 가능</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/79929ee9-8d52-4f24-be71-5cd694654c9a/image.png" alt=""></p>
<p>그런데, 만약 <code>Personal</code> 옵션으로 에셋을 구입했는데 사용하는 게임이 <code>Professional</code> 단계로 넘어간다면 어떻게 해야 할까? 이때는 에셋 제작자와 연락해서 (<code>Professional 에셋 비용</code>-<code>Personal 에셋 비용</code>)만큼의 비용을 지불하고 에셋을 업그레이드하면 된다고 한다. </p>
<hr>
<h2 id="결론">결론</h2>
<p>UE 마켓플레이스에 존재하는 에셋들의 라이센스 정책들이 생각보다 상업적 활용에 관대해서 놀랐다. 이번에 만들 게임은 기능 위주의 게임을 계획중이라 수익에 대한 부분은 따로 신경쓰지 않기로 했다. 하지만, 혹시라도 나중에 귀찮은 상황이 발생할까봐 EpicGames 공식 에셋을 위주로 최소한의 에셋들을 사용하는게 맘 편할 것 같다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Mac으로 Window 원격접속 해보기]]></title>
            <link>https://velog.io/@ryan_ur/Mac%EC%9C%BC%EB%A1%9C-Window-%EC%9B%90%EA%B2%A9%EC%A0%91%EC%86%8D-%ED%95%B4%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@ryan_ur/Mac%EC%9C%BC%EB%A1%9C-Window-%EC%9B%90%EA%B2%A9%EC%A0%91%EC%86%8D-%ED%95%B4%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Tue, 21 Jan 2025 14:15:55 GMT</pubDate>
            <description><![CDATA[<p>게임 개발의 피할 수 없는 운명은 높은 렌더링을 처리하기 위해 다른 개발에 비해서 높은 그래픽 하드웨어 성능이 필요하다는 점이었다. 그래서 항상 외부에서는 게이밍 노트북와 노트북 쿨러와 함께 묵직한 배터리를 함께 들고 다녀야했다. </p>
<p>그러다가 당장 이번 주에 오프라인 게임잼에 참가하게 되었는데 게이밍 노트북을 구할 수가 없었다. 게이밍 노트북을 빌려주는 업체에 연락해볼까하다가 3일 사용에 6~7만원을 지불하는 것이 썩 마음에 내키지 않아 고민하다가 오래전부터 도전해보고 싶었던 집에 있는 로컬 컴퓨터와 나의 mac노트북 간의 원격접속을 해보기로 하였다. </p>
<hr>
<h2 id="1-mac에-windows-app-설치">1. Mac에 Windows App 설치</h2>
<p>다행히도, Mac에서 Window 환경에 원격 접속을 지원해 주는 프로그램이 이미 존재했다. 과거에는 &quot;Remote Desktop&quot;이라는 이름이었지만, 현재는 <code>Windows App</code>라는 이름으로 바뀐듯 했다.</p>
<p>먼저, <code>Windows App</code>으로 LAN 환경에서 테스트를 해보았는데 window로 돌아가는 내 데스크탑의 private IP만 설정에 입력하니 아주 쉽게 Mac -&gt; Window 원격접속이 가능했다.</p>
<p>하지만, 게임잼에 참여하는 환경에서는 당연히 나의 집에 존재하는 라우터의 범위밖에서 로컬로 접근하기 때문에 설정에 publicIP와 게이트웨이 주소를 입력하고 시도해보았다. 이렇게 해보니 접속이 계속 실패하여서 원인 요소를 찾다보다가 Port Forwarding을 처리해주어야 한다는 사실을 알게 되었다.</p>
<p>Port Forwarding은 외부 네트워크에서 특정 포트를 통해 들어오는 요청을 내부 네트워크의 지정된 장치나 서비스로 전달하는 네트워크 설정이다. cmd에 <code>ipconfig</code>를 치면 다양한 네트워크 속성을 확인할 수 있는데 여기서 게이트웨이의 주소를 가지고 이를 인터넷 검색창에 치게 되면 Port Forwarding 할 수 있는 설정창이 나오게 된다. 3389 포트가 Remote Desktop Protocol(RDP)를 관할하는 포트인데 정확히 이 포트로 Port Forwarding 해주면 된다. </p>
<h4 id="windows-app으로-설정한-2개의-instanceslan-환경-wan-환경">Windows App으로 설정한 2개의 Instances(LAN 환경, WAN 환경)</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/12b7e1a2-62ea-4cdd-8ce2-429d5dccc781/image.png" alt=""></p>
<h4 id="wan환경으로-접근하는-instance의-설정값">WAN환경으로 접근하는 Instance의 설정값</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/2e4c396c-0b36-4577-9201-c9acc001ca63/image.png" alt=""></p>
<hr>
<h2 id="2-변경된-public-ip-감지해서-gmail로-전송해주는-python-script-만들기">2. 변경된 Public IP 감지해서 Gmail로 전송해주는 Python Script 만들기</h2>
<p>1번에서 끝났으면 정말 편리하게 원격접속을 했었겠지만 한가지 간과한 사실이 있었다. 바로 publicIP가 때에 따라서 변한다는 것이다. 변동되는 publicIP 주소를 특정 도메인 이름에 연결해 고정된 주소처럼 사용할 수 있게 해주는 DDNS라는 방법이 있는데, 내가 사용하는 SK broadband 라우터에서는 이를 무료로 도와주는 no-IP라는 사이트의 지원을 해주지 않아서 다른 방법을 생각해야 했다.</p>
<p>사실 자신이 사용하는 환경의 publicIP는 <a href="https://www.whatismyip.com/">https://www.whatismyip.com/</a> 와 같은 사이트로 매우 쉽게 확인할 수 있다. <a href="https://api.ipify.org">https://api.ipify.org</a> 같은 주소에서는 아예 REST API형태로 이를 지원해주고 있었고, 여기서 publicIP를 주기적으로 확인하고 있다가 변경된 시점이 있으면 이를 이메일로 알려주는 python script를 작성해 보았다. </p>
<pre><code class="language-python">import requests
from datetime import datetime
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart

# Email 설정
EMAIL_ADDRESS = &quot;---@gmail.com&quot;  # 보내는 이메일
EMAIL_PASSWORD = &quot;---&quot;    # Gmail Auth 비번
TO_EMAIL = &quot;---@gmail.com&quot;  # 받을 이메일
SMTP_SERVER = &quot;smtp.gmail.com&quot;            # Gmail SMTP server
SMTP_PORT = 587                           # SMTP Port 

# IP 주소 저장하는 text 파일
IP_FILE = &quot;last_ip.txt&quot;

def get_public_ip():
    &quot;&quot;&quot;public IP fetch&quot;&quot;&quot;
    try:
        response = requests.get(&quot;https://api.ipify.org?format=json&quot;)
        response.raise_for_status()
        return response.json()[&quot;ip&quot;]
    except requests.RequestException as e:
        print(f&quot;Error fetching public IP: {e}&quot;)
        return None

def send_email_notification(old_ip, new_ip):
    &quot;&quot;&quot;public IP 변경시 email 전송함수&quot;&quot;&quot;
    try:
        # Email 생성
        msg = MIMEMultipart()
        msg[&quot;From&quot;] = EMAIL_ADDRESS
        msg[&quot;To&quot;] = TO_EMAIL
        msg[&quot;Subject&quot;] = &quot;Public IP Address Changed&quot;

        body = (
            f&quot;Your public IP has changed:\n\n&quot;
            f&quot;Old IP: {old_ip}\n&quot;
            f&quot;New IP: {new_ip}\n&quot;
            f&quot;Timestamp: {datetime.now()}&quot;
        )
        msg.attach(MIMEText(body, &quot;plain&quot;))

        # SMTP server에 연결 -&gt; email 전송
        with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as server:
            server.starttls()
            server.login(EMAIL_ADDRESS, EMAIL_PASSWORD)
            server.send_message(msg)
        print(&quot;Notification sent.&quot;)
    except Exception as e:
        print(f&quot;Error sending email: {e}&quot;)

def get_last_ip():
    &quot;&quot;&quot;가장 최근 public IP fetch&quot;&quot;&quot;
    try:
        with open(IP_FILE, &quot;r&quot;) as file:
            return file.read().strip()
    except FileNotFoundError:
        return None

def save_ip(new_ip):
    &quot;&quot;&quot;IP text 파일에 저장&quot;&quot;&quot;
    with open(IP_FILE, &quot;w&quot;) as file:
        file.write(new_ip)

def main():
    current_ip = get_public_ip()
    if not current_ip:
        return  

    last_ip = get_last_ip()
    if current_ip != last_ip:
        print(f&quot;Public IP changed from {last_ip} to {current_ip}&quot;)
        send_email_notification(last_ip, current_ip)
        save_ip(current_ip)
    else:
        print(&quot;No change in public IP.&quot;)

if __name__ == &quot;__main__&quot;:
    main()
</code></pre>
<h4 id="성공적으로-메일-수신">성공적으로 메일 수신!</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/727cfd0d-c12a-468b-b7d8-a2e4fb067561/image.png" alt=""></p>
<hr>
<h2 id="3-window-task-scheduler로-일정-시간마다-python-script-실행">3. Window Task Scheduler로 일정 시간마다 Python Script 실행</h2>
<p>물론, 작성한 python script를 그냥 IDE에서 돌려 버릴 수도 있겠지만 IDE를 실수로 꺼버릴 수도 있고 작업 표시줄에 굳이 이 작업이 보여질 필요가 없겠다라는 생각에 AWS Lambda와 같이 자동으로 script를 실행해주는 프로그램의 사용을 생각해보고 있었다. 그러던 와중, window에 이와 같은 작업을 지원해주는 built-in 프로그램인 <code>Task Scheduler</code>이 있다는 사실을 알게 되었다.</p>
<p><code>Task Scheduler</code>에 Task를 추가하기 위해서는 이 Task가 언제 trigger 될 것인지, 어떤 작업을 수행할 것인지, 얼마나 자주 수행할 것인지에 대한 설정을 입력하여야 한다. 여담이지만 어떤 작업을 수행할 것인지(Task의 동작 부분)에 대한 설정에서 작동하지 않는 python 경로를 기입해서 많은 시간을 허비했다.(cmd에 <code>where python</code>을 입력하면 python설치 경로가 뜨게 되는데, 직접 그 경로로 들어가서 잘 작동되는지 수동으로 다시 한번 확인해보자!) </p>
<h4 id="동작-속성에는-다음과-같이-python-경로-script-파일-이름-script-파일-위치를-차례로-입력">동작 속성에는 다음과 같이 python 경로, script 파일 이름, script 파일 위치를 차례로 입력</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/566964e7-b2d4-42f6-9b02-0d9b7ae47a00/image.png" alt=""></p>
<h4 id="task-scheduler에-성공적으로-task추가">Task Scheduler에 성공적으로 Task추가!</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/548ce888-ef66-4a8e-9ede-80d44f1bfade/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[webRTC로 화상통화 만들어보기!]]></title>
            <link>https://velog.io/@ryan_ur/webRTC%EB%A1%9C-%ED%99%94%EC%83%81%ED%86%B5%ED%99%94-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@ryan_ur/webRTC%EB%A1%9C-%ED%99%94%EC%83%81%ED%86%B5%ED%99%94-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Tue, 10 Dec 2024 01:35:45 GMT</pubDate>
            <description><![CDATA[<h1 id="webrtc">webRTC</h1>
<p>webSocket과 SocketIO를 거쳐 이제 대망의 webRTC를 다뤄보자. 다시 한번 recap 하자면 webSocket은 HTTP와 비슷하게 server-client 모델을 가지고 있지만, 클라이언트에서 서버로 단방향 request가 아닌 서로에게 request, response가 가능한 구조이다. webRTC는 webSocket과 같이 RTC(Real Time Communication)이 가능하지만, TCP가 아닌 UDP 베이스며 클라이언트끼리 한번 채널이 생성되고 나면 서버를 통하지 않고 P2P로 직접 소통이 가능하다.  </p>
<p>webRTC는 javascript에서 네이티브로 지원해주고 바로 모던 웹 브라우저에서 사용 가능하기 때문에 별도의 설치는 필요하지 않다. </p>
<h1 id="stream에서-track-가져오기">Stream에서 Track 가져오기</h1>
<p>Stream과 Track에 대해 잠시 알아보자. Track은 오디오 트랙, 미디어 트랙, 자막 트랙과 같이 단일 데이터 컴포넌트라 하면 Stream은 이 Track들의 집합체이다. 보통 webRTC에서는 <code>navigator.mediaDevices.getUserMedia()</code>로 Media Stream을 가져오고 이 Stream으로부터 <code>Stream.getVideoTracks()</code>, <code>Stream.getAudioTracks()</code>로 개별 트랙에 접근할 수 있다. </p>
<h1 id="signaling-server란">Signaling Server란?</h1>
<p>webRTC의 가장 도드라진 점은 client들끼리 server의 개입없이 P2P로 통신할 수 있다는 점이다. 하지만, client끼리의 초기 채널을 뚫기 위해서는 몇몇 서버가 개입될 필요가 있는데 그중 가장 핵심적인 서버가 바로 이 signaling server이다. </p>
<p>Signaling server로 초기 채널을 만들기 위해서는 크게 2번의 작업이 필요하다. 1번째 작업은 client들끼리 한번의 핑퐁 과정을 거처 각각의 local, remote description을 생성하는 것이고, 2번째 작업은 피어들이 서로 소통할 수 있는 수단인 ICE candidate들을 모다 다시 한번 핑퐁의 과정을 거친다. 이를 그림과 코드로 자세히 알아보자. </p>
<h4 id="첫-번째-핑퐁localremote-description-생성">첫 번째 핑퐁(Local/Remote description 생성)</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/46112298-8b73-4064-b00c-6e35acf3dade/image.png" alt=""></p>
<pre><code class="language-javascript">//app.js
...

// Step 1. 자신이 만든 방에 누군가 들어오면 offer를 생성해서 
// 자신의 local description을 만들고, 들어온 사람에게 offer를 전송
socket.on(&quot;welcome&quot;, async ()=&gt;{
    const offer = await myPeerConnection.createOffer()
    myPeerConnection.setLocalDescription(offer)
    socket.emit(&quot;offer&quot;, offer, roomName)
    console.log(offer)
})

// Step 2. 방에 입장한 사람은 Step 1에서 전달된 offer를 통해 자신의 remote description을 만든다.
// 그 후, answer를 만들고 이를 통해 자신의 local description을 만든다.
// 만든 answer은 방의 생성자에게 다시 전달
socket.on(&quot;offer&quot;, async(offer) =&gt; {
    myPeerConnection.setRemoteDescription(offer)
    const answer = await myPeerConnection.createAnswer()
    myPeerConnection.setLocalDescription(answer)
    socket.emit(&quot;answer&quot;, answer, roomName)
})

// Step 3. 방의 생성자는 Step 2에서 전달된 answer를 통해 자신의 remote description을 만든다.
// 이로써 방의 생성자와 참가자 모두에게 각각 local, remote description이 1개씩 생성되었다. 
socket.on(&quot;answer&quot;, answer=&gt;{
    myPeerConnection.setRemoteDescription(answer)
})</code></pre>
<h4 id="두-번째-핑퐁ice-candidates-주고-받기">두 번째 핑퐁(ICE candidates 주고 받기)</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/89a5cca9-0734-46c6-9d3f-27e9e332b985/image.png" alt=""></p>
<pre><code class="language-javascript">//app.js

// Step 1. 각각의 클라이언트에서 방에 입장하게 되면 RTC 커넥션 객체를 생성하게 되는데,
// 여기서 &quot;icecandidate&quot;라는 이벤트를 듣는 이벤트 리스너와 handleIce라는 콜백 함수를 만든다. 
myPeerConnection = new RTCPeerConnection()
myPeerConnection.addEventListener(&quot;icecandidate&quot;, handleIce)

function handleIce(data){
    socket.emit(&quot;ice&quot;, data.candidate, roomName)
}

// Step 2. emit된 &quot;ice&quot; 이벤트를 받으면 넘어온 ice 데이터를 addIceCandidate 함수에 넣어서 실행 
socket.on(&quot;ice&quot;, ice =&gt;{
    myPeerConnection.addIceCandidate(ice)
})
</code></pre>
<h1 id="stun-server란">STUN Server란?</h1>
<h4 id="nat의-등장">NAT의 등장!</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/c748fabd-730f-44c7-b024-d586d2add5d4/image.png" alt=""></p>
<p>분명 webRTC는 서버를 쓰지 않는다고 했는데, 또 다른 서버인 STUN server가 또 등장했다! 이건 또 뭘까? 우리가 local 환경에서 사실 이 서버는 필요가 없지만 온라인으로 나가게 된다면 우리의 local 단말기들은 NAT라는 장치를 거쳐 나가게 된다. 여기서 이 STUN 서버의 역할은 클라이언트로 하여금 자신의 public IP 주소를 찾을 수 있게 도와준다. </p>
<p>그럼 클라이언트는 자신의 public IP주소를 모른다는 것일까? 결론부터 말하자면 맞다! NAT안에 있는 클라이언트들은 자신의 private IP(192.168.x.x나 10.x.x.x)를 사용하게 되고, 이것이 NAT을 통해 publicIP로 변환되는 과정은 클라이언트가 알지 못한다. </p>
<p>이를 우리의 프로젝트로 가져온다면, 클라이언트는 STUN 서버에게 “나의 public IP 주소와 포트번호를 알려줘”라고 요청을 한다. 이 정보로, webRTC를 통신할 수 있는 채널을 만든다. </p>
<p>다행히도 구글에서 무료로 제공하고 있는 STUN 서버들이 있다(<a href="https://dev.to/alakkadshaw/google-stun-server-list-21n4">STUN 서버 링크</a>). 이를 활용해서 코드에 추가해보자. </p>
<h1 id="turn-server">TURN server</h1>
<h4 id="방화벽의-등장">방화벽의 등장</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/6f80b6b4-ab1e-4ff3-8575-fe7589529caf/image.png" alt=""></p>
<p>우리의 보안망은 튼튼해서 NAT에 더불에 방화벽까지 존재할 수 있다. 방화벽은 클라이언트끼리 직접적인 webRTC 소통을 막을 수 있는데, 이를 해결하기 위해서는 방화벽을 해지하거나 아니면 중간에 TURN 서버 경유하여서 통신할 수 있다. </p>
<p>STUN 서버까지는 클라이언트에게 자신의 publicIP와 port를 알려준다음 P2P 통신을 가능하게 하지만, TURN 서버가 들어오는 순간서부터는 모든 정보가 TURN 서버를 거쳐 도달하기 때문에 사실상 P2P 통신이라 부르기 힘들다. </p>
<h1 id="데모">데모!</h1>
<h4 id="ngrox로-공개-url을-얻을-수-있다">Ngrox로 공개 url을 얻을 수 있다.</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/17854c73-9f38-4c3c-a090-cbe27dc192cf/image.png" alt=""></p>
<p>지금 프로젝트에서는 express로 localhost에서 호스팅하고 있기 때문에 이를 외부에서 접근하려면 추가적인 작업을 해야한다! Localhost로 서빙을 해도 이를 외부 url로 만들어 주는 localtunnel이라는 플러그인이 있는데 현재 관리가 잘 되고 있지 않아 잘 작동하지 않았다. 그래서 찾아보다가 ngrox 플러그인을 찾게 되었고 성공적으로 잘 작동했다! </p>
<p>처음에 사이트에 가입하고 auth token을 받아 관련 command를 terminal 돌려주는 작업이 필요하다. 그후, terminal 창을 2개띄워서 하나는 server.js를 돌리는 terminal, 다른 하나는 server.js에서 설정한 포트번호로 ngrox command를 돌리면 된다! </p>
<h4 id="랜딩-페이지에서-방-생성-화면">랜딩 페이지에서 방 생성 화면</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/217c91b6-3c73-4370-83b8-cce87d259bb3/image.png" alt=""></p>
<h4 id="노트북---핸드폰과-실시간-화상-통화">노트북 &lt;-&gt; 핸드폰과 실시간 화상 통화</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/4f5a72d6-cb90-4824-a1dd-d7a2879b0819/image.png" alt=""></p>
<p>나는 노트북에서 &quot;Ryan&quot;이란 이름을 방을 생성하였고, 핸드폰으로 접속해 같은 방 이름을 치고 들어왔다. 왼쪽은 노트북 화면이고 오른쪽은 핸드폰 화면이다. 각측에서 오디오 stream과 비디오 stream을 토글시킬 수 있는 버튼들인 &quot;Mute&quot;와 &quot;Turn Camera Off&quot;이 각각 존재하고 그 밑에는 streaming하는 디바이스의 어떤 카메라를 사용할 수 있는지 선택할 수 있는 버튼이 있다. 모바일 카메라와 들어오니 해상도가 달라서 오른쪽 UI가 약간 깨져 버튼이 밑으로 밀려버렸다. </p>
<p>이번 실습에서는 Video와 Audio에 대해서만 실시간 stream을 보냈지만, webRTC로는 채팅, 게임 패킷, 파일 등 무긍무진하게 P2P로 보낼 수 있다. 웹 통신은 대부분 server-client 모델을 가지고 있는 것을 생각하면 webRTC는 client끼리 바로 통신이 가능하다는 엄청난 강점이 있고, 프로젝트의 방향성에 맞다면 webRTC을 적극 사용해보면 좋을 것이다. </p>
<hr>
<h1 id="코드">코드</h1>
<p><a href="https://github.com/jerryhtw/webRTC_test/tree/webRTC">https://github.com/jerryhtw/webRTC_test/tree/webRTC</a></p>
<hr>
<h1 id="reference">Reference</h1>
<p><a href="https://support.medialooks.com/hc/en-us/articles/360000213312-%D0%95nvironment-signaling-STUN-and-TURN-servers">https://support.medialooks.com/hc/en-us/articles/360000213312-%D0%95nvironment-signaling-STUN-and-TURN-servers</a></p>
<p>노마드 코더
<a href="https://www.youtube.com/@nomadcoders">https://www.youtube.com/@nomadcoders</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[SocketIO 프레임워크로 chat 기능 고도화하기]]></title>
            <link>https://velog.io/@ryan_ur/SocketIO-%ED%94%84%EB%A0%88%EC%9E%84%EC%9B%8C%ED%81%AC%EB%A1%9C-chat-%EA%B8%B0%EB%8A%A5-%EA%B3%A0%EB%8F%84%ED%99%94%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@ryan_ur/SocketIO-%ED%94%84%EB%A0%88%EC%9E%84%EC%9B%8C%ED%81%AC%EB%A1%9C-chat-%EA%B8%B0%EB%8A%A5-%EA%B3%A0%EB%8F%84%ED%99%94%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 08 Dec 2024 15:42:42 GMT</pubDate>
            <description><![CDATA[<h1 id="socketio-프레임워크란">SocketIO 프레임워크란?</h1>
<p>socketIO는 실시간 통신을 아주 쉽게 사용할 수 있도록 만들어진 JS 기반 프레임 워크이다. 기본적으로 webSocket을 사용하지만 방화벽이나 통신 상태에 따라 HTTP의 polling 기능을 포함하여 다양한 방법으로 실시간 통신을 가능하게 한다. </p>
<p>SocketIO는 바닐라 webSocket에 비해 엄청나게 많은 유틸들을 제공해주는데 가장 확실하게 체감되었던 몇 가지 부분을 소개해보면 다음과 같다. </p>
<p>첫 번째로, 이벤트를 보내고 받는 것이 매우 쉬워졌고 확장성이 증가되었다. SocketIO도 바닐라 webSocket과 같이 client&lt;-&gt;server에서 서로 메시지를 주고 받는 4가지 경로가 있는 점을 동일하지만, SocketIO에서는 &quot;socket.emit&quot;에서 즉석으로 이벤트를 정의할 수 있고 이를 수신하는 쪽에서는 단순히 해당 이벤트를 &quot;socket.on&quot;으로 받으면 된다. 또한, 클라이언트에서 &quot;socket.emit&quot;의 인자로 콜백함수를 서버에게 넘겨줄수도 있는데, 서버는 이를 받으면 연결된 클라이언트들에게 이 콜백함수를 실행시킨다. </p>
<p>두 번째로, room이라는 개념을 지원한다. 이 &quot;room&quot;은 우리가 생각하는 채팅방과 동일한 기능을 한다. SocketIO는 이 room과 관련된 다양한 함수들을 지원주어서 해당 room에 어떤 client들이 있는지 출력, 특정 client들을 room으로 보내기/퇴장기능, server가 특정 room에게 메시지 보내기 등이 손쉽게 가능해졌다. </p>
<p>세 번째로, 통합 관리 시스템인 admin panel을 제공해준다. 이를 통해, 미리 준비된 UI로 현재 서버에 접속한 client들, byte 전송상태, 트리거된 이벤트들을 파악 가능하다.</p>
<h4 id="socketio-서버-코드">SocketIO 서버 코드</h4>
<pre><code class="language-javascript">// server.js
import express from &quot;express&quot;;
import http from &quot;http&quot;;
import {Server} from &quot;socket.io&quot;;
import {instrument} from &quot;@socket.io/admin-ui&quot;;

const app = express();

// http 스타일의 req, res style
app.set(&quot;view engine&quot;,&quot;pug&quot;);
app.set(&quot;views&quot;, __dirname + &quot;/views&quot;);
app.use(&quot;/public&quot;, express.static(__dirname + &quot;/public&quot;));


app.get(&quot;/&quot;, (req,res) =&gt;res.render(&quot;home&quot;));
app.get(&quot;/*&quot;, (req,res) =&gt;res.redirect(&quot;/&quot;));

const handleListen = () =&gt; console.log(`Listening on http://localhost:3000`);
const httpServer = http.createServer(app);
const wsServer = new Server(httpServer, {
    cors : {
        origin : [&quot;https://admin.socket.io&quot;],
        credentials : true
}});

instrument(wsServer, {
    auth : false,
});


function publicRooms(){
    const {
        sockets : {
            adapter : {sids, rooms},
        }
    } = wsServer;

    const publicRooms = [];
    rooms.forEach((_, key)=&gt;{
        if(sids.get(key) === undefined){
            publicRooms.push(key);
        }
    })
    return publicRooms;
}

function countRoom(roomname){
    return wsServer.sockets.adapter.rooms.get(roomname)?.size;
}

wsServer.on(&quot;connection&quot;, socket=&gt;{
    socket[&quot;nickname&quot;] = &quot;Anonymous&quot;
    // app.js에서 만든 &quot;enter_room&quot; 이벤트에 대해 반응
    socket.on(&quot;enter_room&quot;, (roomName, callback)=&gt; {
        // console.log(roomName);
        // join을 통해 roomName 이름의 room에 입장
        socket.join(roomName);
        // socket.to =&gt; 특정 room에 msg 보내기
        // socket.leave =&gt; 특정 room 나가기
        // socket.id =&gt; client socket의 고유 id를 알 수 있음
        // socketsJoin =&gt; 특정 client socket을 강제로 특정 room에 join시킬 수 있음. 

        callback();

        socket.to(roomName).emit(&quot;welcome&quot;, socket.nickname, countRoom(roomName));
        wsServer.sockets.emit(&quot;room_change&quot;, publicRooms());

    });

    socket.on(&quot;disconnecting&quot;, ()=&gt;{
        socket.rooms.forEach(room =&gt; {
            socket.to(room).emit(&quot;bye&quot;, socket.nickname, countRoom(room)-1 )
        });
    })

    socket.on(&quot;new_message&quot;, (msg, roomName, callback)=&gt;{
        socket.to(roomName).emit(&quot;new_message&quot;, `${socket.nickname} : ${msg}`)
        callback()
    })
    socket.on(&quot;nickname&quot;, nickname =&gt;socket[&quot;nickname&quot;] = nickname);    
});


httpServer.listen(3000, handleListen);

</code></pre>
<h4 id="socketio-클라이언트-코드">SocketIO 클라이언트 코드</h4>
<pre><code class="language-javascript">// app.js
const socket = io();

// socketIO에서는 기본적으로 연결되어 있는 client를 저장하는 map구조가 존재한다. 

const welcome = document.getElementById(&quot;welcome&quot;)
const form = document.querySelector(&quot;#welcome form&quot;);
const room = document.getElementById(&quot;room&quot;)
room.hidden = true;

let roomName;

function addMessage(message){
    const ul = room.querySelector(&quot;ul&quot;);
    const li = document.createElement(&quot;li&quot;)
    li.innerText = message
    ul.appendChild(li);
}

function handleMessageSubmit(event){
    event.preventDefault();
    const input = room.querySelector(&quot;#msg input&quot;);
    const msgvalue = input.value;
    socket.emit(&quot;new_message&quot;, input.value, roomName, ()=&gt;{
        addMessage(`You ${msgvalue}`)
    })   
    input.value = &quot;&quot;; 
}

function handleNicknameSubmit(event){
    event.preventDefault();
    const input = room.querySelector(&quot;#name input&quot;);
    socket.emit(&quot;nickname&quot;, input.value);
}

function handleRoomSubmit(event){
    event.preventDefault();
    const input = form.querySelector(&quot;input&quot;);

    // emit 1번째 인자 : 즉석에서 &quot;enter_room&quot;이라는 event를 바로 만들어 버릴 수 있다. 
    // emit 2번째 인자 : socketIO에서는 전송할때 꼭 objecy를 string으로 바꾸지 않고 emit 함수를 통해 바로 object를 보낼 수 있다. 
    // emit 3번째 인자 : callback 함수를 보낼 수 있다. 헷갈릴 수 있지만, 이 함수는 server 쪽에서 실행되는 것이 아닌 server와 연결된 client에서 실행되는 것. server쪽에서는 이 callback 함수를 받아서 client 쪽에서 어떻게 이 함수를 실행하는지 설정할 수 있다.
    socket.emit(&quot;enter_room&quot;, input.value, ()=&gt;{
        console.log(&quot;server is done!&quot;);
        welcome.hidden = true;
        room.hidden = false;    
        const h3 = room.querySelector(&quot;h3&quot;);
        h3.innerText = `Room ${roomName}`;
        const msgform = room.querySelector(&quot;#msg&quot;)
        const nameform = room.querySelector(&quot;#name&quot;)
        msgform.addEventListener(&quot;submit&quot;, handleMessageSubmit)
        nameform.addEventListener(&quot;submit&quot;, handleNicknameSubmit)
    });
    roomName = input.value;
    input.value = &quot;&quot;;
}

form.addEventListener(&quot;submit&quot;, handleRoomSubmit);

socket.on(&quot;welcome&quot;, (user, newCount)=&gt;{
    const h3 = room.querySelector(&quot;h3&quot;);
    h3.innerText = `Room ${roomName} (${newCount})`;    
    addMessage(`${user} has joined!`)
})

socket.on(&quot;bye&quot;, (user, newCount)=&gt;{ 
    const h3 = room.querySelector(&quot;h3&quot;);
    h3.innerText = `Room ${roomName} (${newCount})`;             
    addMessage(`${user} has left!`)
})

socket.on(&quot;new_message&quot;, (msg)=&gt;{
    addMessage(msg)
})

socket.on(&quot;room_change&quot;, (rooms)=&gt;{
    if(rooms.length === 0){
        roomList.innerHTML = &quot;&quot;;
        return;
    }
    const roomList = welcome.querySelector(&quot;ul&quot;)
    rooms.forEach(room =&gt; {
        const li = document.createElement(&quot;li&quot;);
        li.innerText = room;
        roomList.append(li);
    });
})</code></pre>
<p>socketIO를 사용한 코드에서는 room의 유틸을 사용해 실제 채팅방에 접속해서 사용자들이 실시간 통신을 할 수 있고, 앞에 닉네임을 prefix로 붙여서 누가 무슨말을 했는지 시각적으로 보이는 기능을 추가했다.  </p>
<h4 id="2명의-클라이언트가-같은-채팅방에-입장-후-채팅하는-모습">2명의 클라이언트가 같은 채팅방에 입장 후 채팅하는 모습</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/f0c414ea-b3b9-4a56-baa8-7eb99ae3e92c/image.gif" alt=""></p>
<hr>
<h1 id="소스-코드">소스 코드</h1>
<p><a href="https://github.com/jerryhtw/webRTC_test/tree/socketIO">https://github.com/jerryhtw/webRTC_test/tree/socketIO</a></p>
<hr>
<h1 id="reference">Reference</h1>
<p>socketIO
<a href="https://socket.io/">https://socket.io/</a></p>
<p>노마드코더
<a href="https://www.youtube.com/@nomadcoders">https://www.youtube.com/@nomadcoders</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[바닐라 webSocket으로 실시간 통신하기]]></title>
            <link>https://velog.io/@ryan_ur/webRTC</link>
            <guid>https://velog.io/@ryan_ur/webRTC</guid>
            <pubDate>Sat, 07 Dec 2024 02:14:55 GMT</pubDate>
            <description><![CDATA[<h1 id="websocket-webrtc를-들여다보게-된-계기">websocket, webRTC를 들여다보게 된 계기</h1>
<p>9월달에 한창 게임 클라이언트 포트폴리오를 준비하면서 아쉬웠던 점이 내가 만든 게임을 다른 사람들에게 온라인으로 직접 플레이 시킬 방법이 없었다는 점이다. 아무리 동영상과 블로그 글에 개발 일지를 잘 적어놔도, 구조적으로 다른 사람들이 나의 게임을 간접 체험할 수 밖에 없는 전달의 한계에 부딪혔다. </p>
<p>이후, UE 서버를 공부하면서 멘토분께 pixelstreaming이라는 방법이 있다는 것을 알게 되었다. 이는 마치 우리가 유튜브로 생중계하는 게임을 보는 것과 비슷한데 차이점이 있다면 실시간으로 자신의 디바이스에서 직접 게임을 플레이할 수 있다는 것이다! 이는 UE 게임의 무거운 용량을 클라이언트의 디바이스에 직접 설치하지 않고 서버에 대신 올리므로서, 사용자가 쾌적하게 게임을 플레이 가능하기 때문에 다음 프로젝트에 무조건 사용해봐야 겠다고 생각했다.</p>
<p>하지만 언리얼 공홈에서 pixelstreaming관련 문서를 읽어보았을때 STUN server, TURN server, NAT, webRTC, SDP와 같은 네트워크 용어들이 무더기로 나왔다. 좀 더 알아본 결과 이는 webRTC의 개념을 이해하여야 한다는 것을 깨달았고 이에 대한 선행학습을 하기로 결정했다. 운이 좋게도 인터넷에 노마드 코더님이 express를 활용한 webSocket과 webRTC 튜토리얼이 있어서 이를 실습해보았다.  </p>
<hr>
<h1 id="갑자기-왠-websocket">갑자기 왠 webSocket?!</h1>
<p>webRTC를 본격적으로 들어가기 전에 HTTP와 webSocket에 대해서 먼저 알아보자. 우리가 인터넷에서 정보를 주고 받는 HTTP 통신규약은 client가 server에게 request할때만 response를 하는 반면, webSocket은 client 와 server가 언제든지 서로 이벤트를 주고 받을 수 있다. 더 나아가 webRTC는 server 자체를 없에서 client들끼리 P2P 통신이 가능하다. </p>
<p>이번 블로그 글은 javascript에 있는 바닐라 webSocket으로 어떻게 사용자들끼리 메시지를 실시간으로 보낼 수 있는지 알아보자. </p>
<h1 id="http에서-request-response">HTTP에서 request, response</h1>
<p>HTTP가 작동하는 방식은 마치 서버에게 &quot;서버야, 데이터 좀 줘&quot; 라고 요청을 날릴때만 서버는 데이터를 주게 된다. 하지만, 절대로 서버가 먼저 데이터를 보내는 경우는 없다. 이는 즉, HTTP가 stateless라는 말과 같은데, 전의 기록은 다 잊어버리고 client가 request 한 순간에 대해서만 response 하기 때문이다. </p>
<h4 id="http-코드">HTTP 코드</h4>
<pre><code class="language-javascript">// HTTP의 request, response 방식
app.set(&quot;view engine&quot;,&quot;pug&quot;);
app.set(&quot;views&quot;, __dirname + &quot;/views&quot;);
app.use(&quot;/public&quot;, express.static(__dirname + &quot;/public&quot;));


app.get(&quot;/&quot;, (req,res) =&gt;res.render(&quot;home&quot;));
app.get(&quot;/*&quot;, (req,res) =&gt;res.redirect(&quot;/&quot;));</code></pre>
<p>위 코드에서도 client가 특정 url로 get request를 보낼때에만 정해진 로직에 맞추어서 화면을 렌더링해주는 것을 볼 수 있다. </p>
<h1 id="websocket">WebSocket</h1>
<p>webSocket은 HTTP와 같이 client-server 모델이고 TCP 기반이라는 공통점이 있지만, 양방향 통신이 가능하는 엄청난 차이점이 있다. webSocket은 HTTP과 같이 OSI의 7계층의 속하며 HTTP와 같이 엄연한 하나의 통신규약이다. 그래서 HTTP의 url이 &quot;http://&quot;와 같이 나가는 것처럼 websocket은 &quot;ws://&quot;로 나가게 된다. </p>
<h4 id="websocket-서버-코드">webSocket 서버 코드</h4>
<pre><code class="language-javascript">// server.js
const server = http.createServer(app);
const wss = new WebSocket.Server({server});

wss.on(&quot;connection&quot;, (socket)=&gt;{
    console.log(&quot;Connected to Browser&quot;);
    socket.on(&quot;message&quot;, (message)=&gt;{
        console.log(&quot;Incoming message is : &quot;, message.toString());
    })
    socket.on(&quot;close&quot;, ()=&gt;{
        console.log(&quot;Disconnected from the browser&quot;);
    });
    socket.send(&quot;hello!&quot;);
});

server.listen(3000, handleListen);</code></pre>
<p>위 코드는 동일한 포트에 ws와 http 채널을 동시에 열도록 web server를 만든 것이다. 이는 간단하게 ws의 반응과 http 렌더링을 동시에 보기 위함이고, 당연히 각각 다른 포트에 구현하거나 하나만 구현해도 전혀 상관없다.</p>
<p>서버의 작동 방식은 직관적인데, server.js에서는 socket.on이라는 함수가 event listener로 작동하게 되어서 frontend의 event(여기서는 &quot;message&quot;, &quot;close&quot;)를 받게 되면 바인딩되어있는 콜백함수가 실행되게 된다. socket.send로는 연결되어 있는 client에게 메시지를 반대로 보낼 수 있다. </p>
<h4 id="websocket-클라이언트-코드">webSocket 클라이언트 코드</h4>
<pre><code class="language-javascript">// app.js
const socket = new WebSocket(`ws://${window.location.host}`);

socket.addEventListener(&quot;open&quot;,()=&gt;{
    console.log(&quot;Connected to Server&quot;);
});

socket.addEventListener(&quot;message&quot;, (message)=&gt;{
    console.log(&quot;New message : &quot;, message.data);
})

socket.addEventListener(&quot;close&quot;, ()=&gt;{
    console.log(&quot;Disconnected from server!&quot;);
})

setTimeout(()=&gt;{
    socket.send(&quot;hello from the browser!&quot;);
},10000);</code></pre>
<p>server.js에서는 localhost:3000에 websocket과 http 채널을 열었는데 app.js에서는에서는 window.location.host로 여기에 접속하는 코드이다. </p>
<p>app.js에서는 반대로 server단에서 보낸 event들을 socket.addEventListener로 듣거나 socket.send로 보낼 수 있다. </p>
<p>정리하자면 webSocket에서는 client가 server에게 메시지를 보낼수도, client가 server에서 보낸 메시지를 받을수도, server가 client에게 메시지를 보낼수도, server가 client에서 보낸 메시지를 받을수 있다(총 4가지). </p>
<p>이를 통해 webSocket 통신에서는 server&lt;-&gt;client 각각의 측에서 메시지를 자유롭게 받고 보낼 수 있다. 이는 HTTP 통신규약에서 server가 client의 request에만 반응하는 구조와 확연히 다른 모습을 보인다. </p>
<h4 id="http-vs-websocket">HTTP vs webSocket</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/e850b817-5205-4bb2-a8c4-8ebac8058d07/image.png" alt=""></p>
<hr>
<h2 id="개선된-websocket-chat">개선된 webSocket chat</h2>
<p>위의 코드에서는 단일 client와 server 간의 ws 통신이였다면 우리는 server에 연결된 client들끼리의 실시간 통신을 위해 webSocket을 실습해 보고 있는 것이기 때문에 코드를 살짝 개선해보자. </p>
<h4 id="개선된-websocket-서버-코드">개선된 webSocket 서버 코드</h4>
<pre><code class="language-javascript">// server.js

// server와 ws 연결된 client 배열로 저장
const sockets = [];

wss.on(&quot;connection&quot;, (socket)=&gt;{

    sockets.push(socket);
    // default nickname 설정
    socket[&quot;nickname&quot;] = &quot;anonymous&quot;;
    console.log(&quot;Connected to Browser&quot;);
    socket.on(&quot;message&quot;, (message)=&gt;{
        const parsed = JSON.parse(message);
        if(parsed.type === &quot;nickname&quot;){
            socket[&quot;nickname&quot;] = parsed.payload;
        }else if((parsed.type === &quot;new_message&quot;)){
          // UE의 multicast처럼 server는 자신과 연결된 모든 client들에게 받은 내용을 broadcast
            sockets.forEach(aSocket=&gt;aSocket.send(`${socket.nickname} : ${parsed.payload}`));
        }
    })
    socket.on(&quot;close&quot;, ()=&gt;{

        console.log(&quot;Disconnected from the browser&quot;);
    });
});

server.listen(3000, handleListen);</code></pre>
<p>서버에서는 연결된 client의 정보를 싹다 리스트에 저장해놓는다. 이는 &quot;connection&quot; 이벤트의 &quot;socket&quot; 객체를 통해 가능하다. 그런 다음, client가 메시지를 보내는 이벤트인 &quot;message&quot;를 받게 되면 저장되어 있는 모든 client들에게 받은 메시지를 다시 전달한다.  </p>
<h4 id="개선된-websocket-클라이언트-코드">개선된 webSocket 클라이언트 코드</h4>
<pre><code class="language-javascript">// app.js

const socket = new WebSocket(`ws://${window.location.host}`);

const messageList = document.querySelector(&quot;ul&quot;);
const nickForm = document.querySelector(&quot;#nick&quot;); 
const messageForm = document.querySelector(&quot;#message&quot;); 

// server에는 JS object를 보내는 것이 아니라 이를 string으로 만들어서 보내주는 것이 확장성 면에서 좋다.(예시는 express로 server를 만들었지만, server가 꼭 JS 베이스일 것이라는 보장은 없기 때문)
function makeMessage(type, payload){
    const msg = {type, payload}
    return JSON.stringify(msg);
}

socket.addEventListener(&quot;open&quot;,()=&gt;{
    console.log(&quot;Connected to Server&quot;);
});

// server로부터 message를 받으면 이를 fronend의 list에 추가해준다.
socket.addEventListener(&quot;message&quot;, (message)=&gt;{
    const li = document.createElement(&quot;li&quot;);
    li.innerText = message.data;
    messageList.append(li);
    console.log(&quot;New message : &quot;, message.data);
})

socket.addEventListener(&quot;close&quot;, ()=&gt;{
    console.log(&quot;Disconnected from server!&quot;);
})

// 메시지 보내는 부분
function handleSubmit(event){
    event.preventDefault();
    const input = messageForm.querySelector(&quot;input&quot;);
    socket.send(makeMessage(&quot;new_message&quot;, input.value));
    input.value = &quot;&quot;;
}

// nickname 보내는 부분
function handleNickSubmit(event){
    event.preventDefault();
    const input = nickForm.querySelector(&quot;input&quot;);
    socket.send(makeMessage(&quot;nickname&quot;, input.value));
    input.value = &quot;&quot;;
}

messageForm.addEventListener(&quot;submit&quot;, handleSubmit)
nickForm.addEventListener(&quot;submit&quot;, handleNickSubmit)</code></pre>
<p>개선된 app.js에서는 간단하게 닉네임을 설정하는 부분과 server에서 broadcast된 메시지를 받으면 메시지를 화면에 출력해주는 코드이다. </p>
<p>개선된 코드에서는 드디어 server와 연결된 client들끼리의 real-time communication이 가능하게 되었다!</p>
<hr>
<h1 id="코드-링크">코드 링크</h1>
<p><a href="https://github.com/jerryhtw/webRTC_test/tree/webSocket">https://github.com/jerryhtw/webRTC_test/tree/webSocket</a></p>
<hr>
<h1 id="reference">Reference</h1>
<p>노마드코더
<a href="https://www.youtube.com/@nomadcoders">https://www.youtube.com/@nomadcoders</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Github으로 Unreal version control 효과적으로 하기]]></title>
            <link>https://velog.io/@ryan_ur/Github%EC%9C%BC%EB%A1%9C-Unreal-version-control-%ED%9A%A8%EA%B3%BC%EC%A0%81%EC%9C%BC%EB%A1%9C-%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@ryan_ur/Github%EC%9C%BC%EB%A1%9C-Unreal-version-control-%ED%9A%A8%EA%B3%BC%EC%A0%81%EC%9C%BC%EB%A1%9C-%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 25 Nov 2024 02:45:42 GMT</pubDate>
            <description><![CDATA[<h1 id="git과-github의-사용-목적">Git과 Github의 사용 목적</h1>
<p>프로젝트를 개발하는데에 있어서 Git을 사용하면, 내가 과거에 했던 프로젝트로 되돌아갈수 있다는 장점이 있다. 또한, 추가적으로 Github을 사용하면 과거의 시점을 캡쳐에 클라우드에 올릴 수 있어서 다양한 사람들과의 협업&amp;자신이 작업했던 로컬 환경에 변화가 생겼을때 유연하게 개발할 수 있다는 장점이 있다!</p>
<hr>
<h1 id="언리얼-게임-개발시-초기-git-설정">언리얼 게임 개발시 초기 Git 설정</h1>
<p>나름 고군분투하면서 어떤 방법으로 초기 Git을 설정하는 것이 효과적인지 테스트를 해보았다. </p>
<ol>
<li>Github Destop과 Git을 컴퓨터에 설치한다.</li>
<li>Github Desktop으로 &quot;New Repository&quot;를 통해 로컬에 git과 .gitignore 파일을 생성한다.(여기서 꼭!! gitignore를 언리얼 엔진용으로 생성해야 한다!)</li>
<li>생성한 Repository를 Github Desktop에서 &quot;push&quot;를 통해 자신의 github에 올린다.</li>
<li>Unreal Engine으로 새로운 프로젝트를 2번에서 생성한 폴더에 생성한다. </li>
<li>git add를 하기 전에 추후 게임 개발에 사용할 에셋들을 미리 추가하고 이 에셋들의 경로들을 .gitignore에 추가한다. </li>
<li>git add-&gt;git commit을 통해 git을 업데이트하고 git push를 통해 github repository를 관리한다.</li>
</ol>
<p>특히 강조하고 싶은 부분이 있는데, 언리얼로 게임 개발을 하는데에는 큰 용량을 가진 에셋들이 종종 사용된다. Github에 push할때 각 파일의 용량이 100MB이상이면 중간에 오류나 발생함으로 <code>.gitignore</code> 파일을 신경써서 작성해주는 것이 정말로 중요하다. (2번) 과정에서 Github Desktop으로 Unreal용 <code>.gitignore</code> 파일을 생성하고 (5번) 과정에서 자신이 나중에 추가한 에셋들의 파일 경로들만 잘 추가해준다면 큰 문제는 없을 것이다:)</p>
<hr>
<h1 id="git-명령어들">Git 명령어들</h1>
<h4 id="git-add">git add</h4>
<p>바꾼 파일을 &quot;git add&quot;를 통해 임시 저장상태인 staging area로 보낼 수 있다. </p>
<h4 id="git-commit">git commit</h4>
<p>staging area에 존재하고 있는 파일들을 최종 snapshot을 찍는 것이다. &quot;git checkout&quot;을 통해서 본격적으로 version control을 수행할 수 있는데, 여기서 &quot;git commit&quot;으로 생성된 snapshot들 중 하나를 골라서 되돌아갈 수 있다. &quot;git commit -am&quot;을 쓰면 add와 commit을 한꺼번에 할 수 있다. </p>
<h4 id="git-push">git push</h4>
<p>이것은 github 원격저장소에 snapshot한 결과를 올리는 과정이다. origin에 원격 저장소의 url을 등록해 놓았다면 git push origin [branch 이름]으로 코드를 올릴 수 있다.  </p>
<h4 id="git-checkout-commit-해시값">git checkout [commit 해시값]</h4>
<p>과거의 commit한 git 상태로 되돌아갈 수 있다. </p>
<h4 id="git-lfs">git LFS</h4>
<p>git으로 원격저장소에 push를 하면 한 파일의 크기 제한이 100MB에 걸리게 된다. 이를 해결하는 방법으로는 git LFS(Large File Storage)를 사용할 수 있다. 하지만 유료이므로 상황에 맞게 사용하자. </p>
<h4 id="push할-repo-주소-선택">push할 repo 주소 선택</h4>
<p>git remote add origin : 처음으로 원격 저장소 주소 만들때
git remote set-url origin : 이미 원격저장소 주소 있지만 바꾸고 싶을때
git remote -v : 현재 등록되어 있는 원격 저장소 링크 확인</p>
<h4 id="gitignore">.gitignore</h4>
<p>git rm -r --cached . : .gitignore를 수정하였는데 이를 확실하게 반영하고 싶을때는 기존의 캐시를 날려주고 git add .를 다시 실행
git ls-files : 현재 git이 tracking하고 있는 file들을 보여준다. </p>
<hr>
<h4 id="unreal용-gitignore-템플릿">Unreal용 .gitignore 템플릿</h4>
<pre><code class="language-.gitignore"># Visual Studio 2015 user specific files
.vs/

# Compiled Object files
*.slo
*.lo
*.o
*.obj

# Precompiled Headers
*.gch
*.pch

# Compiled Dynamic libraries
*.so
*.dylib
*.dll

# Fortran module files
*.mod

# Compiled Static libraries
*.lai
*.la
*.a
*.lib

# Executables
*.exe
*.out
*.app
*.ipa

# These project files can be generated by the engine
*.xcodeproj
*.xcworkspace
*.sln
*.suo
*.opensdf
*.sdf
*.VC.db
*.VC.opendb

# Precompiled Assets
SourceArt/**/*.png
SourceArt/**/*.tga

# Binary Files
Binaries/*
Plugins/*/Binaries/*

# Builds
Build/*

# Whitelist PakBlacklist-&lt;BuildConfiguration&gt;.txt files
!Build/*/
Build/*/**
!Build/*/PakBlacklist*.txt

# Don&#39;t ignore icon files in Build
!Build/**/*.ico

# Built data for maps
*_BuiltData.uasset

# Configuration files generated by the Editor
Saved/*

# Compiled source files for the engine to use
Intermediate/*
Plugins/*/Intermediate/*

# Cache files for the editor to use
DerivedDataCache/*

# 나중에 추가한 게임 에셋 경로 추가하기</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[게임상에서 NFT 민팅해보기!!]]></title>
            <link>https://velog.io/@ryan_ur/NFT-%EB%AF%BC%ED%8C%85%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@ryan_ur/NFT-%EB%AF%BC%ED%8C%85%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 14 Sep 2024 08:38:41 GMT</pubDate>
            <description><![CDATA[<h1 id="nft-minting">NFT Minting</h1>
<p>오늘은 <strong>게임내에서 NFT를 민팅</strong>하는 마법같은 일을 해볼 것이다. NFT의 가격 거품이 많이 빠진 지금이지만, 필자는 게임 NFT는 <strong>실제 가치를 기반으로 하는 아이템을 베이스</strong>로 하고 있다는 점과 나중에 <strong>게임과 게임을 이어주는 거대한 메타버스</strong>에서 빈번히 쓰일 것이라는 믿음으로 아주 낙관적으로 생각하고 있다. 이 때문인지 이번 프로젝트를 하면서 정말 즐거웠다. </p>
<h2 id="bitcoin과-ethereum의-각기-다른-탄생-배경">Bitcoin과 Ethereum의 각기 다른 탄생 배경</h2>
<p><strong>NFT</strong>는 처음 <code>ERC-721</code>기반으로 세상에 등장하였다. ERC는 이더리움 기반인데 왜 비트코인을 기반으로 NFT를 만들지 않았을까? 그 이유를 알기 위해서는 <code>Bitcoin</code>과 <code>Ethereum</code>의 각기 다른 탄생 배경을 알아보아야 한다. </p>
<p><strong>Bitcoin</strong>의 탄생 철학은 2008년 <strong>리만 브라더스 사태</strong>로 거슬러 올라간다. 이 사태를 짧막하게 요약하자면,  여러 미국 은행들이 국내 집 값 상승에 배팅하고자 자신들이 보유할 안전 자산에 정크 채권들이 마구 섞인 CDO를 넣기 시작하였고, 채권 심사 기관들은 뒷돈을 받고 이런 쓰레기 채권들에게 최고 안전 등급인 AAA급 기준을 부여하였다. 이들은 미국의 기준 금리가 올라가는 신호탄에 맞춰 도미노로 붕괴되기 시작했고, 이 때 미국 연준의 의장이였던 벤 버냉키라는 사람이 달러를 마음껏 찍어내는 방법인 듣도 보지 못한 <strong>양적완화(QE)</strong>를 도입하였다. </p>
<p>이 사태를 이해한다면 왜 비트코인의 최종 수량이 정해져 있는지 알 수 있을 것이다. 이에 반해 <strong>이더리움</strong>은 그 철학이 <strong>Programmable Contract</strong>에 있다. 즉, <strong>통화</strong>보다는 <strong>계약</strong> 자체를 탈중앙화하는 것에 초점이 정해져 있다.</p>
<p>많이 들어봤을 <code>NFT</code>도 이더리움의 등장으로 만들어진 토큰인데, 이번에 직접 NFT Contract를 <strong>Sepolia Network</strong> 상에서 배포해보고, Unreal 상에서 직접 <code>NFT</code> 민팅을 할 수 있는 기능을 만들어보았다. 이번 프로젝트의 플로우는 다음과 같다. </p>
<h4 id="overall-workflow">Overall Workflow</h4>
<ol>
<li><strong>NFT 에셋(Metadata 포함)</strong> 및 <strong>IPFS</strong> 환경 설정 </li>
<li>NFT Minting 하는 <strong>Smart Contract</strong> 작성 및 Sepolia Testnet에 배포</li>
<li>Unreal에서 Smart Contract의 <strong>Minting 함수 호출</strong></li>
</ol>
<hr>
<h2 id="1-nft-에셋-준비">1. NFT 에셋 준비</h2>
<h3 id="nft의-정보는-어디에-저장될까">NFT의 정보는 어디에 저장될까?</h3>
<p><strong>NFT</strong>의 이미지 정보가 어떻게 블록체인에 올라가는지 처음 접하는 사람은 충격을 받을 수 밖에 없다 . 그 이유는 <strong>NFT</strong>의 창조 철학 자체가 디지털 에셋 대한 소유권을 주장하는 것이지만, 정작 에셋의 크기를 다 블록체인에 올릴수가 없어 <code>AWS</code>나 <code>IPFS</code>와 같은 분산 네트워크에 에셋을 올린 다음에 그 링크만 블록체인에 올리기 때문이다. 물론 디지털 에셋을 올리면 이를<code>CID(Content Identifier)</code>로 변환하는 작업을 하는데 에셋이 이미지라 가정하면 원본 이미지로부터 <strong>1 pixel</strong>만 달라져도 이 <code>CID</code>가 달라지기 때문에 위조 작품을 걸러내기는 용이하다. 하지만, <strong>AWS나 IPFS와 같은 분산 네크워크</strong>가 날라가거나 해킹당하는 경우에는 디지털 에셋의 저장 주소 자체가 타격을 받는 것이기 때문에 아주 위험하다. </p>
<p><code>Ethereum</code>이 아닌 가스비가 상대적으로 낮은 <code>Polygon</code>이나 <code>Solana</code> 같은 경우는 이미지 원본을 올리는 경우도 있지만 나는 <code>Ethereum</code>이 강력한 팬이기 때문에 <code>Sepolia Testnet</code>에서 <strong>IPFS</strong>를 통해 <strong>NFT를 minting</strong>하는 대중적인 방법을 사용해보겠다.  </p>
<h3 id="nft-metadata-준비">NFT Metadata 준비</h3>
<p><strong>NFT</strong>를 구성하는 정보를 <code>Metadata</code>라고 한다. 가장 메인이 되는 정보(이미지, 동영상, 음악 파일 등등)이 있고 그 정보가 어떤 이름인지, 몇 번의 창작물인지, 어떤 collection에 속해있는지에 대한 텍스트 정보가 있다.</p>
<p>이 정보들을 하나의 링크로 만들기 위해서는 <code>IPFS</code>에 두번의 파일 업로드 과정을 거쳐야 한다. <strong>첫 번째 작업</strong>은 메인이 되는 정보 파일의 업로드이다. <strong>두 번째 작업</strong>은 첫 번째로 얻은 링크 정보 + 부가적인 텍스트 정보를 다 포함한 <strong>JSON</strong> 파일의 업로드이다.  </p>
<h4 id="생성형-ai로-이미지-제작">생성형 AI로 이미지 제작</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/535b98e0-8e22-41fe-9cc0-e5f4e9ec8aff/image.png" alt=""></p>
<p>먼저, <strong>NFT</strong>로 사용될 이미지를 준비한다. 그 전에, 이더리움 기반 NFT 토큰의 종류에 대한 이해가 필요하다. 이더리움 기반의 코인에는 그 성격에 따라서 <strong>ERC-(숫자)</strong>가 붙는다. 이더리움 <strong>Native Token</strong>은 <code>ERC-20</code>기반이고 <strong>NFT</strong>는 <code>ERC-721</code>, <code>ERC-1155</code>가 보편적인 기반으로 쓰인다. <code>ERC-721</code>은 이 세상에 하나밖에 없는 재화(보통 미술작품)에 많이 사용되고 <code>ERC-1155</code>는 하나는 아니지만 소유권을 부여하고 싶을때(한정판 명품, 희귀한 게임 아이템)을 제작할 때 사용한다.</p>
<p>필자는 게임에서 사용될 <strong>NFT</strong>를 만드려고 함으로 <code>ERC-1155</code>기반을 만들 것이다. 게임 아이템 중 기밀 정보에 해당하는 아이템이 있는데 이를 나타내는 이미지를 무료 이미지 생성형 AI 툴을 사용해서 위와 같이 뽑아내었다.    </p>
<h4 id="ipfs에-nft-meta-정보가-들어간-json-업로드">IPFS에 NFT meta 정보가 들어간 JSON 업로드</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/e5b54972-0d86-4e23-9d7d-b15899c058a2/image.png" alt=""></p>
<p>이미지를 얻었으면 이를 위에서 설명한대로 <strong>IPFS</strong>에 올리고 그 image URI를 포함한 MetaData를 JSON 형식으로 만들어 다시 <strong>IPFS</strong>에 올린다. 이 <strong>JSON URI</strong>가 NFT Smart Contract에 들어갈 최종적인 input이 된다.  </p>
<hr>
<h2 id="2-nft-smart-contract-작성-및-배포">2. NFT Smart Contract 작성 및 배포</h2>
<p>블록체인에 올릴 <strong>Token URI</strong>는 준비되었으니 이제 이를 올리는 Smart Contract를 만들고 배포하면 된다. 하지만, Contract을 작성하는 데 몇 번의 우여곡절이 있었다(사실 현재 진행중...). 초반 Contract(<strong>Minting Contract Version 1</strong>라고 부르겠다)으로는 <code>ERC-1155</code>로 민팅은 되는데 <code>Opensea</code>에서 <code>IPFS</code>에 대한 <strong>URI</strong>, <strong>Thumbnail</strong>에 대한 어떤 정보도 얻을 수 없었다. 이를 위해서 Contract이 인자로 받는 JSON과 uri getter 함수를 손봐야 했다. </p>
<h4 id="opensea-nft-json-규약">Opensea NFT Json 규약</h4>
<pre><code>{
  &quot;name&quot;: &quot;MarsEscape Final&quot;,
  &quot;description&quot;: &quot;This is an ERC-1155 NFT with an animated GIF thumbnail for OpenSea.&quot;,
  &quot;image&quot;: &quot;ipfs://QmRuZNxjMwKmW1UVot8wBwVpkbMVtFgQVkWxJ6HhVRYXv1&quot;,
  &quot;animation_url&quot;: &quot;ipfs://QmRuZNxjMwKmW1UVot8wBwVpkbMVtFgQVkWxJ6HhVRYXv1&quot;,
  &quot;external_url&quot;: &quot;https://velog.io/@ryan_ur/posts&quot;,
  &quot;attributes&quot;: [
    {
      &quot;trait_type&quot;: &quot;Rarity&quot;,
      &quot;value&quot;: &quot;Legendary&quot;
    },
    {
      &quot;trait_type&quot;: &quot;Artist&quot;,
      &quot;value&quot;: &quot;Ryan&quot;
    }
  ]
}</code></pre><p>우선, Opensea가 요구하는 JSON의 property를 위와 같이 확실하게 작성해 주어야 했다. 제일 중요한 부분이 Thumbnail로 활용될 <strong>Image</strong>값과 메타 정보인 <strong>Collection</strong>, <strong>Description</strong> 필드값이다.   </p>
<h3 id="minting-contract">Minting Contract</h3>
<p>다음으로는 <strong>Contract</strong> 부분이다. <strong>NFT Minting</strong>의 경우에는 워낙 많이 쓰이기 때문에 아예 <code>OpenZeppelin</code>이라는 library에서 Minting에 필요한 많은 함수들을 지원한다. 예를 들어, <code>ERC-1155</code> 클래스를 지원해서 <code>ERC-1155</code> 토큰 베이스를 만들고 싶다면 단순히 상속하면 된다. 또한, 접근 권한성에 해당하는 <code>Ownable modifier</code>도 제공하지만 Contract의 Compile과 배포를 했던 <strong>Remix</strong> 환경에서는 계속 에러가 나서 Ownable은 사용하지 못하고 권한이 있는 계정은 Contract에 주소를 하드 코딩 해두었다. </p>
<h4 id="minting-contract-version-1">Minting Contract Version 1</h4>
<pre><code class="language-cpp">// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import &quot;@openzeppelin/contracts/token/ERC1155/ERC1155.sol&quot;;

contract GameAssetNFT is ERC1155 {
    // MINTER의 주소는 하드코딩했다. 
    address public constant MINTER = 0x6655551b6C9C53C6a7da81118a9227c0F0c54e80;

    // TokenId 인덱스를 만들어 private mapping의 key값으로 활용한다. 
    uint256 public nextTokenId; 
    mapping(uint256 =&gt; string) private _tokenURIs; 

    constructor() ERC1155(&quot;&quot;) {
        nextTokenId = 1; 
    }

    // Minter 주소를 가진 사람만이 minting 함수에 접근하도록 modifier 생성 
    modifier onlyMinter() {
        require(msg.sender == MINTER, &quot;Caller is not the minter&quot;);
        _;
    }

    function mintGameAsset(address recipient, uint256 amount, string memory tokenURI) public onlyMinter {
        uint256 tokenId = nextTokenId;
        _mint(recipient, tokenId, amount, &quot;&quot;);
        _setTokenURI(tokenId, tokenURI); 
        nextTokenId++;
    }

    function _setTokenURI(uint256 tokenId, string memory tokenURI) internal {
        require(bytes(tokenURI).length &gt; 0, &quot;Invalid token URI&quot;);
        _tokenURIs[tokenId] = tokenURI;
    }

    // Index로 IPFS 링크에 접근할 수 있는 getter 함수 생성
    function uri(uint256 tokenId) public view override returns (string memory) {
        return _tokenURIs[tokenId];
    }
}</code></pre>
<p>하지만, 이렇게 만들고 테스트를 해보았더니 <strong>ERC-1155</strong> 기반으로 민팅은 되지만, 정작 Opensea에 들어가보니 이미지와 상세 설명이 모두 공란이었다. 조사를 해보니 Contract에 있는 uri getter 함수의 리턴값이 Opensea에서 요구하는 값과 달라서 생긴 일이었다.   </p>
<h4 id="minting-contract-version-2">Minting Contract Version 2</h4>
<pre><code class="language-cpp">// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import &quot;@openzeppelin/contracts/token/ERC1155/ERC1155.sol&quot;;
import &quot;@openzeppelin/contracts/utils/Strings.sol&quot;;

contract GameAssetNFT is ERC1155 {
    address public constant MINTER = 0x6655551b6C9C53C6a7da81118a9227c0F0c54e80;
    uint256 public nextTokenId;

    constructor() ERC1155(&quot;ipfs://QmbyNrycL7fJoWEHWe5kXSpCoKoqDtxrdJdbV15ybqGoLT/{id}.json&quot;) {
        nextTokenId = 1; 
    }

    modifier onlyMinter() {
        require(msg.sender == MINTER, &quot;Caller is not the minter&quot;);
        _;
    }

    function mintGameAsset(address recipient, uint256 amount) public onlyMinter {
        uint256 tokenId = nextTokenId;
        _mint(recipient, tokenId, amount, &quot;&quot;);
        nextTokenId++;
    }

    // URI 함수 수정
    function uri(uint256 tokenId) public pure override returns (string memory) {
        return string(abi.encodePacked(&quot;ipfs://QmbyNrycL7fJoWEHWe5kXSpCoKoqDtxrdJdbV15ybqGoLT/&quot;, Strings.toString(tokenId), &quot;.json&quot;));
    }
}
</code></pre>
<p>초기 Contract에서 uri getter 함수를 수정한 <strong>Minting Contract Version 2</strong>를 만들고 배포하였다. URI 함수의 리턴값을 IPFS 주소 + tokenID 값 + .json으로 만든 다음에 <code>abi.encodePacked</code> 을 최종적으로 거치게 하였더니 Opensea에서 원하는 대로 성공적인 토큰이 리스팅되었다 .   </p>
<h4 id="opensea에서-확인한-minting-된-erc-1155-토큰들">Opensea에서 확인한 minting 된 ERC-1155 토큰들</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/b1aa2bc5-51c6-452e-b172-6903950454d2/image.png" alt=""></p>
<p>빨간색 박스 안 토큰들은 <strong>Minting Contract Version 1</strong>의 결과물들이고, 초록색 박스 안 토큰들은 <strong>Minting Contract Version 2</strong>의 결과물들이다. </p>
<h4 id="contract-정보-저장">Contract 정보 저장</h4>
<p><strong>Contract 정보</strong>를 저장하는데도 약간의 생각이 필요했다. <strong>Contract</strong>를 배포하고 이의 함수를 사용하기 위해서는 Contract의 <strong>ABI 정보</strong>와 <strong>주소 정보</strong>가 필요하다. Minting Contract Version <strong>Etherscan</strong>에서는 Contract의 <strong>&quot;Verify and Publish&quot;</strong>를 하면 ByteCode로만 보여주고 있던 코드를 ABI를 포함해, 원래 배포했던 solidity 코드 원본의 정보까지 보여준다.  하지만, 이를 하려고 하니 Etherscan에 compile 체크를 할때 OpenZeppelin 라이브러리를 지원하지 않는지 계속 실패했다.</p>
<p>어쩔 수 없이 이 정보들을 저장하기 위해 Contract의 기본 정보가 들어간 Struct를 BP를 사용해서 만들고, 이 Struct를 기본 자료형으로 하는 DataTable을 추가적으로 만들었다. </p>
<h4 id="contract-정보를-관리하는-custom-datatable-제작">Contract 정보를 관리하는 custom DataTable 제작</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/0618cc52-e3e4-4c44-9936-fd9c49443350/image.png" alt=""></p>
<h4 id="datatable에서-정보를-fetch하는-모습">DataTable에서 정보를 fetch하는 모습</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/b89533d3-1277-4b53-95a9-707776b5a7dd/image.png" alt=""></p>
<hr>
<h2 id="3-blockchain-interaction">3. Blockchain Interaction</h2>
<p>Smart Contract까지 배포했으면 이제 게임상에서 Minting된 Contract에 접근해 <strong>Mint 함수</strong>를 부르기만 하면 된다. 앞선 블로그에서 <strong>코인 전송</strong>을 하기 위해 <code>Sign Transaction</code>과 <code>sendRawTransaction</code>을 차례로 보냈던 것을 기억해보자(<a href="https://velog.io/@ryan_ur/WEB3-%EA%B2%8C%EC%9E%84%EC%9D%98-%EC%8B%9C%EC%9E%91-%EC%BD%94%EC%9D%B8-%EC%A0%84%EC%86%A1">링크</a>). 코인 전송에 있어서는 <code>Sign Transaction</code>의 Data Field가 공란이었다면, 배포된 Contract와 Interaction 하기 위해서는 <code>Sign Transaction</code>의 Data Field에 <strong>Contract ABI 정보</strong>를 추가적으로 넣어주기만 하면 된다. </p>
<h4 id="abi-정보를-인코딩해서-datafield에-넣기">ABI 정보를 인코딩해서 DataField에 넣기</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/af7a8088-999e-43cc-91fe-28a40d91531a/image.png" alt=""></p>
<p>JSON으로 되어있는 ABI정보를 그냥 넣을 수 없으므로 인코딩하는 작업이 필요하다. <strong>3SGameStudio</strong>에서 제공하는 <code>Encode Smart Contract ABI</code> 함수를 사용해서 이 작업을 수행했다.  </p>
<hr>
<h2 id="inventory-시스템-구현">Inventory 시스템 구현</h2>
<p>추후에는 게임내에서 아이템을 주으면 사용자가 자기가 원하는 아이템을 민팅하는 기능을 만들 계획이다. 이를 위해서 NFT 아이템 클래스와 Inventory 기능을 만들었다.  </p>
<p>아이템 클래스는 <strong>BP_Item</strong>로 만들었고 여기에 이 아이템의 수량, 이름, Thumbnail, StaticMesh에 대한 정보(Struct로 관리)를 넣었다. 충돌 부분은 <code>Multi Sphere Trace By Channel</code>을 사용해 Hit Result에 들어온 물체들이 BP_Item이면 이를 Inventory에 넣는 작업을 했다. 처음에는 Sphere Trace를 BP_Item에 달았는데, 이렇게 하면 Player Character에 정보를 접근하는게 번거로워서 Sphere Trace를 처리하는 <strong>Actor Component</strong>를 만들고 이를 캐릭터에 붙였다. </p>
<p>UI는 각각의 인벤토리에 해당하는 <strong>Sub widget</strong>과 Inventory에 해당하는 <strong>Main-widget</strong>을 만들었다. Sub Widget에서는 Overlay위에 Image와 TextBox를 함께 넣어 적재한 아이템의 Thumbnail과 수량을 표시할 수 있게 하였다. Main-widget의 경우에는 획득한 아이템들을 보여주고 선택한 아이템을 Minting할 수 있도록 버튼을 따로 만들어주었다. </p>
<h4 id="e-key를-누르면-inventory에-적재">E key를 누르면 Inventory에 적재</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/9f99dde6-4cf9-472f-80f7-3281e887e08e/image.gif" alt=""></p>
<hr>
<h2 id="추후-남은-과제">추후 남은 과제</h2>
<p>Opensea에서 Thumbnail로 정적인 이미지만 들어가는 것이 아쉬워 <strong>Thumbnail을 GIF 형식</strong>으로 만들고 싶었다. 나중에 다른 아이템들도 이런 Thumbnail을 만들어야 할 것 같아 아예 레벨안에서 스튜디오를 만들어보기로 하였다. 사실 정말 간단한데, 하얀색 판자로 뼈대를 만들고 Spotlight를 위에 달아서 메인 물체에 대한 강조 효과를 주었다.  </p>
<h4 id="gif-thumbnail을-촬영하기-위해-제작한-스튜디오">GIF Thumbnail을 촬영하기 위해 제작한 스튜디오</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/e03c0b2a-5143-47c2-b455-c0b1e4967640/image.png" alt=""></p>
<h4 id="스튜디오에서-촬영한-결과">스튜디오에서 촬영한 결과</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/f77184ea-e3f1-4007-90d7-980be4362865/image.gif" alt=""></p>
<p>3D 아이템의 모든 부분을 보여주고 싶어 아이템 클래스의 <strong>Tick</strong>에서 <code>Set Actor Rotation</code>를 통해 Yaw값을 계속 변하게 만들어서 아이템이 공중에서 회전하는 이펙트를 만들었다.  </p>
<p>하지만... 아쉽게도 GIF 형식으로 Thumbnail을 만드는 데는 실패하였다. 찾아본 결과 JSON에서 <code>image</code>가 아닌 <code>animated-url</code>이라는 key값에 GIF에 해당하는 IPFS를 연결하면 된다고 하는데 잘 되지 않았다. 계속 한번 도전해 봐야겠다. </p>
<p>추가적으로 2개의 작업을 해보고 싶다. <strong>하나는</strong>, 현재 Remix에서 Contract을 배포후 게임상에서 interaction을 하는데, 발전시킨다면 게임 상에서 직접 Contract 배포까지 만드는 것이다. </p>
<p><strong>두번째는</strong> 게임내에서 랜덤 박스를 먹었을때 확률에 따라서 에픽/희귀/보통에 해당하는 아이템을 민팅해보는 것이다. 이를 위해서는 각각의 아이템에 해당하는 사진 혹은 GIF 파일의 제작과 랜덤 함수가 들어간 발전된 Contract을 다시 제작&amp;배포 하는 과정이 필요할 것이다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[WEB3 게임의 시작 - 코인 전송]]></title>
            <link>https://velog.io/@ryan_ur/WEB3-%EA%B2%8C%EC%9E%84%EC%9D%98-%EC%8B%9C%EC%9E%91-%EC%BD%94%EC%9D%B8-%EC%A0%84%EC%86%A1</link>
            <guid>https://velog.io/@ryan_ur/WEB3-%EA%B2%8C%EC%9E%84%EC%9D%98-%EC%8B%9C%EC%9E%91-%EC%BD%94%EC%9D%B8-%EC%A0%84%EC%86%A1</guid>
            <pubDate>Wed, 11 Sep 2024 07:14:24 GMT</pubDate>
            <description><![CDATA[<p>나의 WEB3 여정을 살펴보면, 처음에는 알트 코인 투자로 시작했다가 작년부터 기술에 대한 본격적인 관심을 가지게 되어서 <a href="https://www.linkedin.com/in/taeuk-ham-938a56231/recent-activity/all/">여러 블록체인 해커톤</a>의 참여 및 <a href="https://www.amazon.com/Crypto-Hipsters-Best-Guidebook-Beginners/dp/1543781543">저서 활동</a>까지 이어져 왔다. 불과 2년전까지만 해도 송도에 있는 제약회사에서 AI 모델을 개발하고 있었는데 지금은 언리얼 게임 개발에 블록체인 모듈을 통합하는 작업을 하고 있다는 사실이 신기하기만 하다. </p>
<p>각설하고, 내가 개발하고 있는 TPS 게임에 NFT minting 기능을 붙이기 위해 언리얼 플러그인을 알아보던 도중 <strong>3S Game Studio</strong>라는 회사의 제품을 발견하게 되었다. 기본적인 기능들은 블루프린트 모듈로 제공하고 있었고 Ethereum 관련 네트워크에서 <code>getBalance</code>, <code>getTransactionCount</code>, <code>SignTransaction</code>, <code>SendRawTransaction</code>와 같은 함수들을 추가적인 RPC 설정 없이 편하게 쓸 수 있게 제공해주었다. 내가 만드는 게임에 WEB3 기능을 얹을 수 있다는 부푼 기대감에 바로 작업을 시작하였다.    </p>
<hr>
<h1 id="unreal에서-블록체인-접근하기">Unreal에서 블록체인 접근하기</h1>
<p>이번 개발에서 최종 목표는 NFT의 민팅이고 UI 제작도 플레이어가 가지고 있는 NFT의 목록을 인벤토리 형식으로 보여줄 계획이다. 하지만, 그전에 뭐니뭐니해도 WEB3 프로젝트에서 가장 먼저 시도해 봐야할 기본적인 지갑 기능을 구현해보았다. </p>
<h2 id="개인-주소-세팅">개인 주소 세팅</h2>
<h3 id="rpc-연결">RPC 연결</h3>
<p>만약 개인이 자신의 컴퓨터에서 블록체인 노드를 돌린다면 외부에서 확인할 필요 없이 노드를 통해서 블록체인의 상태를 체크할 수 있다. 하지만, 대부분의 사람들이 그렇지 않으므로 대신 노드를 돌려주고 블록체인의 상태를 확인할 수 있게 제공하는 서비스 업체들이 있다. </p>
<p>이들을 <code>Blockchain RPC Node Provider</code>라고 불리는데 <code>RPC Endpoint</code>에 해당하는 Url을 제공하고 자신이 블록체인에 query하고 싶은 함수를 Json 형식으로 request하면 원하는 정보를 response로 돌려준다.   </p>
<h4 id="rpc-node-provider-들간의-속도-비교">RPC Node Provider 들간의 속도 비교</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/2a676ff4-ccc3-4b46-b0de-b26b3bdefc9a/image.png" alt=""></p>
<p>이미 시중에는 다양한 플레이어들이 있다. 필자는 <strong>RPC Node Provider</strong>로 <code>Alchemy</code>, <code>Infura</code>, <code>Quicknode</code>를 사용해보았는데 개인적으로 <code>Alchemy</code>가 속도적인 면과 사용성이 좋아서 이번 프로젝트에서도 사용하기로 하였다.  </p>
<h4 id="alchemy에서의-개인-project-화면-연동하고-싶은-network를-추가할-수-있다">Alchemy에서의 개인 project 화면. 연동하고 싶은 network를 추가할 수 있다.</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/79e27652-a2e6-4d5e-998e-1efd60041e55/image.png" alt=""></p>
<p>한 가지 주의하여야 할 점은 하나의 <strong>RPC Url</strong>로 각기 다른 블록체인을 접근할 수 있는 것은 아니다. 블록체인은 각각의 네트워크마다 폐쇄된 환경이라는 사실을 명심하자. 자신이 만드는 서비스에서 여러 네트워크에 접근하고 싶다면 <strong>RPC provider</strong>에서 접근하고 싶은 네크워크의 개수만큼 개별 <strong>RPC-Url</strong>이 필요하다. 필자는 <code>Ethereum Mainnet</code>, <code>Ethereum Testnet(Sepolia와 그외)</code>, <code>Polygon zkEVM</code>, <code>Polygon PoS</code>를 사용하기로 하였다. </p>
<p>기존 프로젝트에 붙이는 WEB3 모듈의 최종 기능은 <strong>Opensea</strong>에 <strong>NFT</strong>를 민팅하는 것인데, <strong>Opensea</strong>에서 <code>Sepolia testnet</code>을 지원해준다는 사실을 알았다. 우선, <code>Sepolia Network</code>에서 충분한 테스트 과정을 거친후 <code>Ethereum</code>보다 상대적으로 가스비가 싼 <code>Polygon network</code>에 <strong>NFT</strong>를 민팅할 계획이다.</p>
<p>플러그인에서 <code>Get Blockchain Configuration</code>이라는 함수를 통해 다양한 네트워크에 대한 Blockchain Configuration을 만들 수 있다. 하지만, 테스트 결과 Mainnet 쪽은 문제가 없었지만 Ethereum Testnet에서 query가 안되서 결국 직접 <code>RPC-endpoint</code>를 만들어 네트워크 별 Blockchain Configuration을 만들기로 하였다.     </p>
<h4 id="플러그인에서-지원하고-있는-다양한-네트워크들">플러그인에서 지원하고 있는 다양한 네트워크들</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/5b872809-5929-4ac1-99b3-5f28fcef54de/image.png" alt=""></p>
<h4 id="직접-rpc를-연결해서-만든-네트워크-별-blockchain-config">직접 RPC를 연결해서 만든 네트워크 별 Blockchain Config</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/a1be562a-ea5e-46b7-ac34-7c958e01e187/image.png" alt=""></p>
<h2 id="공개-주소-만들기">공개 주소 만들기</h2>
<p><strong>RPC</strong> 작업이 완료했으니 이제 WEB3 <strong>지갑 주소</strong>를 만들 차례이다. 지갑 주소에 대해서도 할 말이 굉장히 많지만 여기서 다룰 주제는 아닌 듯 하여 이더리움의 accounting system은 비트코인처럼 <code>UTXO</code> 기반이 아닌 <code>Ledger</code> 기반이라는 점과 공개 주소를 위해서는 <code>해시 함수</code>와 <code>타원 곡선 알고리즘</code>이 쓰인다는 점만 간단하게 알고 넘어가자. </p>
<p>이더리움의 Ledger 기반은 우리가 평소에 회계처리를 할때 사용하는 장부 기반이다. A가 2ETH, B가 1ETH를 소유하고 있는 상태에서 A가 B에게 1ETH를 보내면 A는 1ETH, B는 2ETH가 장부에 적히는 것이다. 비트코인의 <code>UTXO</code> 시스템은 아주 독특한 시스템인데 누가 누구에서 전송했다는 사실 하나하나씩 모아둔다. 이들은 당사자가 코인 전송을 하기 전까지 <strong>Unspent</strong> 상태가 되었다가 전송을 할때 <strong>Spent</strong> 상태로 만들면서 그 사실을 제거해버린다. 짧게 설명하기에는 조금 복잡한 내용이므로 더 자세한 내용은 <a href="https://river.com/learn/bitcoins-utxo-model/">여기</a>를 참고해보자. </p>
<p>이제, 우리가 이번 프로젝트에서 직접적으로 다룰 이더리움의 키 생성에 대해서 알아보자. <strong>이더리움의 공개 주소</strong>가 만들어지는 과정은 비트코인의 공개 주소가 만들어지는 원리와 매우 비슷하다. 먼저 Random Seed를 사용한 해시 함수(비트코인은 SHA-256, 이더리움은 secp256k1)를 사용해 256bit의 <strong>Private key</strong>를 만든다.</p>
<p>그런 다음, 이 <strong>Private key</strong>를 <strong>ECDSA(타원 곡선 디지털 서명 알고리즘)</strong>에 넣게 되는데 이 알고리즘도 input에 대해 output이 deterministic하지만 output을 가지고 intput을 역방향 추론할 수 없다는 점에서 해시 함수와 비슷한 면을 가지고 있다. </p>
<h4 id="타원-곡선-알고리즘">타원 곡선 알고리즘</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/5466796d-032e-4532-b187-82f654112b50/image.gif" alt=""></p>
<p><strong>Private key</strong>를 타원 곡선 연산을 하게 되면 그에 해당하는 <strong>Public key</strong>를 얻게 된다. 하지만, 이 값은 주소로 쓰기에는 너무 긺으로 여기서 bit shifting을 통해 자릿수를 줄여 최종 주소를 만들어낸다. </p>
<h4 id="bp에서-키-생성-함수-플로우">BP에서 키 생성 함수 플로우</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/dc7f7097-ec48-4111-aec9-95118b7a75e8/image.png" alt=""></p>
<p>이를 Unreal Blueprint에서 구현하면 다음과 같은 플로우를 가진다. 처음에 <code>Generate Private Key</code>를 통해 키를 생성, 이 <strong>Private key</strong>를 가지고 <code>Generate Public Key</code>로 <strong>Public Key</strong>를 생성, 최종적으로 <code>Generate Ethereum Address from Public Key(Array of bytes)</code>를 통해 <strong>주소</strong>를 얻는다. </p>
<p>한번 생성한 Private Key는 Unreal에서 제공하는 SaveGame Object에 저장해 게임이 꺼져도 <code>~Saved/SaveGames</code> 경로에 binary 파일로 저장되게 된다. <code>Save Game to Slot</code>, <code>Load Game to Slot</code> 등의 함수를 사용해서 private key를 매번 생성하지 않게 한다.  </p>
<h2 id="web3-지갑-기능-붙이기">WEB3 지갑 기능 붙이기</h2>
<p>주소를 생성했다면 이제 주소를 통해 블록체인과 Interaction하는 부분을 만들어보자. WEB3 지갑을 제작하는데에 있어서 핵심 기능은 지갑이 연결되어 있는 <strong>네트워크별 잔고 표시</strong>와 <strong>토큰 송금 및 수신</strong>기능이므로 이 부분을 작업할 것이다.   </p>
<h4 id="metamask의-wallet-ui">MetaMask의 Wallet UI</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/26165e55-4503-46c6-971f-f15bdfa5aa8e/image.png" alt=""></p>
<p><strong>WEB3 지갑</strong>이 무엇인지 모르는 분들을 위해 가장 유명한 WEB3 지갑인 <code>MetaMask</code> wallet UI를 가져왔다. 개인적으로 <code>MetaMask</code>의 깔끔한 디자인이 정말 마음에 든다. <code>MetaMask</code>는 <strong>네트워크별 잔고 표시</strong>와 <strong>토큰 송금 및 수신</strong>기능 외에도 유동성 풀을 통해 토큰을 바꾸는 <strong>스왑</strong>기능, 서로 다른 네트워크 간에도 토큰을 전송할 수 있는 <strong>브리지</strong> 기능과 해당 wallet이 선택된 네트워크 상에서 가지고 있는 NFT를 보여주는 기능을 제공한다. </p>
<h3 id="네트워크-별-잔고-및-nonce-가스비-표시">네트워크 별 잔고 및 Nonce, 가스비 표시</h3>
<p>블록체인에는 <code>Nonce</code>와 <code>Gas fee</code>라는 개념이 존재한다. <code>Nonce</code>는 일종의 counter같은 것인데 한 개의 계정에서 transaction을 날린 횟수만큼 이 counter의 값이 하나씩 증가하게 된다. 동일한 transaction을 시간차를 두고 보내더라도 이 counter의 값을 통해 각각을 차별화 할 수 있다. </p>
<p><code>Gas fee</code>는 블록체인에서 기존 체인에 어떻게 블록이 붙고 이를 어떻게 합의하는 과정을 알아야 전반적인 이해가 가능하다. 블록체인에는 크게 2개의 주체가 있는데, 하나는 WEB3 생태계에서 활동하면서 <strong>transaction을 생성하는 파티</strong>이고 다른 하나는 앞선 파티들이 만든 transaction이 맞는지 validate, nonce(앞서 설명한 nonce와는 다른 개념)를 이리저리 바꾸어 가면서 해시 함수에 넣었을때 특정 길이의 leading zero를 만드는 <strong>Nonce를 구하는 파티</strong>가 존재한다. </p>
<p>여기서 이 2번째 파티를 <code>Miner</code>이라고 하는데 <code>Miner</code>들은 transaction pool에 존재하는 transaction들 중 몇 개를 골라 block을 만들 수 있다(이로 인해 <strong>MEV</strong>와 같은 현상이 일어나기도 한다). 이때 Block에 담을 transaction들은 임의로 고르는 것이 아니라 <code>Miner</code>가 해당 block을 main-chain에 붙였을때 보상을 받게 되는 <strong>Gas Fee</strong>순으로 담게 된다. 따라서 <strong>Gas Fee</strong>는 <code>Miner</code>들에게 돌아가는 보상이고 트잭을 날리는 주체는 높은 <strong>Gas Fee</strong>를 설정해야지 블록에 담길 확률이 올라간다.  </p>
<h4 id="플러그인에서-제공하는-gettransactioncount-gasprice와-getbalance">플러그인에서 제공하는 getTransactionCount, gasPrice와 getBalance</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/dfec9405-48ef-48ba-9f9d-3f86227d0d43/image.png" alt=""></p>
<p>고맙게도 플러그인에서 <strong>Async</strong> 형태로 <code>Transaction Count(Nonce)</code>, <code>gasPrice</code>와 <code>getBalance</code>를 가져오는 함수를 제공하고 있다. 앞서 설정한 <code>Blockchain Config</code>을 통해 해당 함수의 Input으로 넣는다. 헷갈릴 수 있는게 <strong>Nonce</strong>라는 용어는 Transaction Count를 표현하기도 하고 Leading Zero를 구하는 값을 의미하기도 한다. 여기서는 전자의 뜻으로 사용했다. <strong>Gas Price</strong>는 현재 네트워크의 상황을 알아보는 중요한 지표이며 <strong>Get Balance</strong>는 선택한 네트워크의 native token의 수량을 보여준다. <strong>Nonce</strong>와 <strong>Gas Price</strong>은 이따가 <code>Signing Transaction</code>을 할때 필요한 Input들이기도 하다.  </p>
<h4 id="지갑-상에서-네트워크-전환">지갑 상에서 네트워크 전환</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/f1ab75d2-c9fe-4e35-ad96-07c24fa13be5/image.gif" alt=""></p>
<p><code>Transaction Count(Nonce)</code>, <code>gasPrice</code>와 <code>getBalance</code>을 구했다면 이제는 UI를 디자인 해보자. </p>
<p>사용자가 네트워크를 <code>ComboBox</code> 선택창을 통해 고르면, 언리얼 UMG에서 제공하는 <code>ComboBox</code>의 <code>Selection Changed</code> <strong>delegate</strong>를 통해 선택한 네트워크를 Blockchain Config로 설정한다. <code>TxCount</code>와 <code>가스비</code>, <code>잔고</code>에 해당하는 Text에 각각 <strong>Transaction Count</strong>, <strong>Gas Price</strong>, <strong>Balance</strong>를 바인딩한다. 바인딩은 tick마다 값을 없데이트하기 때문에 Blockchain Config에 설정된 네트워크로 변환하자마다 <strong>delta second</strong>이후 바로 값이 업데이트 된다. </p>
<hr>
<h1 id="토큰-전송-기능">토큰 전송 기능</h1>
<h2 id="signing--sending-transaction">Signing &amp; Sending Transaction</h2>
<p>이제 마지막 관문인 <strong>토큰 전송기능</strong>이 남았다. 놀랍게도 토큰 전송은 관련 함수들만 알면 우리가 가지고 있는 값들을 통해 손쉽게 구현 가능하다. </p>
<h4 id="sign-transaction-함수와-sendrawtransaction-함수">Sign Transaction 함수와 sendRawTransaction 함수</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/eb431269-8e87-4996-8fbd-ba62ab075870/image.png" alt=""></p>
<p>먼저 <code>Signing Transaction</code>하는 과정이 있다. 여기에는 <strong>누가(보내는 사람의 Private Key) 누구에게(받는 사람의 Public Key)</strong> 토큰을 전송할지에 대한 정보가 입력된다. 여기에는 <code>비대칭키(Asymmetric key)</code> 암호화 알고리즘이 사용되어서 수신자와 송신자가 동일한 비밀키를 가지지 않아도 안전한 전송이 가능하다.  </p>
<p><strong>송신자의 Private Key</strong>와 <strong>수신자의 지갑 주소</strong> 이외에도 Nonce, Sending Address, Gas price, Gas limit와 같은 값들을 Input 값에 추가적으로 집어 넣은 다음에 <code>sendRawTransaction</code>을 통해 최종적으로 토큰 전송을 해보자.  </p>
<p>주의하여야 할 점은 <code>Signing Transaction</code>와 <code>sendRawTransaction</code>을 할때는 Blockchain으로부터 얻어오는 값을 고정시켜야 한다. 앞서 설명을 못했지만 Wallet UI의 <code>Event Counstrcut</code>에서 <code>Set Timer By Event</code>와 <strong>custom event</strong>를 <strong>delegate</strong>로 연결시켜서 일정 시간마다 Blockchain의 값과 계정 정보를 업데이트 시켰다. 트잭을 보내는 순간에는 업데이트를 멈춰야 함으로 <strong>timer handle</strong>을 가져와 timer를 pause를 시키고 blockchain을 <strong>pending state</strong>로 잠시동안 만들어준다. </p>
<hr>
<h1 id="최종-화면">최종 화면</h1>
<h4 id="sepolia-network를-통해-01eth-전송">Sepolia Network를 통해 0.1ETH 전송</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/d5df984f-8b31-45c3-8227-6558f8c7f129/image.gif" alt=""></p>
<p>먼저, Google에서 제공하는 <strong>Sepolia Faucet</strong>으로 <code>SaveGame</code>에 저장되어 있는 주소에 테스트 토큰을 전송해 놓았다. 그 다음 저장된 주소로부터 내 개인 메타마스크 계정으로 <strong>Sepolia 네트워크</strong> 상에서 0.1ETH 만큼의 토큰을 보내보자. </p>
<p>보내는 주소와 보내는 수량에 각각 수신자의 지갑주소와 0.1ETH를 입력하고 보내기 버튼을 누르면, <strong>Transaction Count</strong>에 해당되는 <strong>TxCount</strong>가 5에서 6으로 변하고 잔고가 0.2ETH에서 0.1ETH로 감소하는 것을 화면상으로 확인할 수 있다!</p>
<h4 id="sepolia-etherscan으로-트잭-확인">Sepolia Etherscan으로 트잭 확인</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/07841d7f-1e5b-4b97-9735-39bd5bca4d76/image.png" alt=""></p>
<p>아직도 믿지 못하겠다면(충분히 그럴만하다) 해당 계정의 전체 트잭 히스토리를 볼 수 있는 <a href="https://sepolia.etherscan.io/">Sepolia Etherscan</a>에서 또한 확인이 가능하다. 긴 주소를 클립보드에 복사하는 기능을 넣기 위해서 <strong>Low Entry</strong>라는 플러그인을 설치했다. 그림에서 빨간색 박스에 표시된 것처럼 성공적으로 트랜잭션이 들어왔다.  </p>
<hr>
<h4 id="reference">Reference</h4>
<p><a href="https://arstechnica.com/information-technology/2013/10/a-relatively-easy-to-understand-primer-on-elliptic-curve-cryptography/">타원 곡선 알고리즘</a></p>
<p><a href="https://www.youtube.com/@3sgamestudio">3S Game Studio</a> </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Unreal Replication ]]></title>
            <link>https://velog.io/@ryan_ur/Unreal-Replication</link>
            <guid>https://velog.io/@ryan_ur/Unreal-Replication</guid>
            <pubDate>Mon, 09 Sep 2024 13:30:13 GMT</pubDate>
            <description><![CDATA[<h1 id="replication이란">Replication이란?</h1>
<p>Unreal Replication이 필요한 이유를 한 문장으로 정리하면, <code>Multiplay Game</code>에서 Server와 Client간의 정보가 동기화되어야 하기 때문이다. Replication을 통해 Server는 연결된 모든 Client들에게 성공적으로 동일한 GameState를 보여줄 수 있다. </p>
<h2 id="replication에서-key-concept">Replication에서 Key Concept</h2>
<ul>
<li><p><strong>Server-Client 구조</strong></p>
<p>혼자서 게임을 하는 경우에는 Player가 임의로 <code>GameState</code>와 <code>GameMode</code>에 접근해도 아무런문제가 되지 않는다. 하지만, 멀티플레이의 경우라면 한명의 Player가 자기 맘대로 게임의 규칙이나 진행중인 게임의 상태(점수 현황, Player의 체력, 무기)를 바꿔버리면 큰일이 나기 때문에 이를 관리하는 권한을 Server에게 전적으로 위임한다.   </p>
</li>
<li><p><strong>Replicated 된 변수 및 Actor</strong></p>
<p>변수의 <code>UPROPERTY</code>의 tag를 통해 이 변수가 Replicated 될지 말지를 설정할 수 있다. Actor의 경우에는 <code>엑터 클래스-&gt;bReplicates = true;</code>를 통해 Actor의 내부 변수, 위치 등을 Replicate 할 수 있다. </p>
</li>
<li><p>RPCs(Remote Procedure Call)</p>
<p>RPC는 Computer Network에서 종종 접했을 수 있는 용어이다. RPC를 사용하면 사실상 remote server에서 함수를 call 하지만, 마치 local에서 call하는 것처럼 손쉽게 Server에 존재하는 함수를 호출할 수 있다. 다른 곳에 존재하는 함수를 RPC로 네트워크에 대한 복잡한 이해없이 간편하게 사용할 수 있다고 생각해보자. </p>
<p>언리얼에서는 총 3가지의 RPC가 있는데, Client-&gt;Server인 <code>Server RPC</code>, Server-&gt;Client인 <code>Client RPC</code>, 마지막으로 Server-&gt;연결된 모든 Client인 <code>Multicast RPC</code>가 있다. </p>
</li>
</ul>
<h4 id="server-rpc의-예시">Server RPC의 예시</h4>
<pre><code class="language-cpp">UFUNCTION(Server, Reliable, WithValidation)
void ServerTakeDamage(int32 DamageAmount);</code></pre>
<p>위의 코드에서 UFUNCTION 2번째 인자에 <code>Reliable</code>이 들어가게 된다. 기본적인 Unreal은 <code>UDP</code>를 사용해서 데이터를 전송하게 되는데 UDP는 속도가 빠른대신 HTTP에 비해 불안정한 성격을 지닌다. Unreal은 상대적으로 더 안정한 UDP를 자체적으로 만들어서 사용하는데 이를 Reliable UDP라 부르고 UPROPERTY의 Reliable tag를 통해 이것을 사용하겠다는 것이다. </p>
<p>사실 모든 변수들이 Reliable 할 필요는 없는데, 예를 들어서 라이플 총소리처럼 다다다다 울리는 소리를 매 프레임마다 아주 정확하게 전달할 필요는 없다. 하지만 사용자의 인벤토리 정보나 체력 등과 같은 중요 정보는 Reliable UDP를 통해 전송하여야 한다.   </p>
<p><strong>* Replication이 필요한 상황</strong></p>
<p>GameState, PlayerState, GameMode에 영향을 주지 않는 변수를 굳이 replicate할 필요가 있을까? 변수마다 존재 이유를 잘 파악해 replicate 할지 말지를 정해보자. </p>
<h2 id="언리얼-주요-클래스-별-replication">언리얼 주요 클래스 별 Replication</h2>
<h4 id="언리얼-주요-클래스들의-clientserver-동작">언리얼 주요 클래스들의 Client/Server 동작</h4>
<p>언리얼 <code>Dedicated Server</code>를 시작하기에 앞서 가장 먼저 파악하여야 하는 것이 언리얼 주요 클래스들이 Server-Side에서는 어떻게 동작하고 Client-Side에서는 어떻게 동작하는 것이다. 하나 더 추가하자면 언리얼 주요 클래스들의 실행 순서이다.  </p>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/a3d8771a-1ca1-4cc8-85f6-ba1c7d8572fc/image.png" alt=""></p>
<p><code>Client-Side</code>에서 볼때, 위 표에서 있는 항목들에 대해서 PlayerController를 제외하고는 Client에 존재하지 않거나 Server로부터 Replicated 된 정보를 받아 ReadOnly인 경우들이다. 다시 한번, Server가 얼마나 강력한 권한을 가진 지 알 수 있다.   </p>
<h4 id="client-side-server-side에-생성되는-언리얼-클래스">Client-Side, Server-Side에 생성되는 언리얼 클래스</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/b0eca970-b571-4ef8-80c4-586dc044e7f9/image.png" alt=""></p>
<h2 id="repnotify">RepNotify</h2>
<p><code>AnimNotify</code>가 Animation Sequence에서 특정 부분에 다다르면 수행될 함수였다는 것을 상기해보자. <code>RepNotify(Replication Notify)</code> 는Replication 처리된 변수가 변경되면 RepNotify로 설정된 함수가 자동으로 호출된다. Delegate와 유사한 느낌이라고 생각하면 된다. </p>
<h3 id="repnotify-사용하는-방법">RepNotify 사용하는 방법</h3>
<ol>
<li>변수를 선언한다. UPROPERTY 안에 <code>ReplicatedUsing</code>라는 MetaData를 세팅하게 되는데 여기에 해당 변수가 업데이트 될때 호출되는 함수의 이름을 적는다. UPROPERTY MetaData에 따로 <code>Replicated</code> 라고 적지 않아도 <code>ReplicatedUsing</code>라고 적으면 자동으로 Replicate한다. </li>
</ol>
<blockquote>
<pre><code class="language-cpp">UPROPERTY(ReplicatedUsing = OnRep_PlayerHealth)
int32 PlayerHealth;</code></pre>
</blockquote>
<pre><code>
  2. 헤더 파일에 함수 선언. OnRep 접두사를 붙여서 함수 이름을 정해준다. 


&gt; ```cpp
UFUNCTION()
void OnRep_PlayerHealth();</code></pre><ol start="3">
<li>OnRep 함수의 구현</li>
</ol>
<blockquote>
<pre><code class="language-cpp">void AMyPlayerCharacter::OnRep_PlayerHealth()
{
    // PlayerHealth 변수가 업데이트 되면 실행 
    UpdateHealthBar(PlayerHealth);
}</code></pre>
</blockquote>
<pre><code>
  4. `GetLifetimeReplicatedProps` 함수의 정의. Replicate가 될 변수들은 모조리 (해당 Class - 변수 이름)의 형태로 이 함수 안에 정의되어야 한다. 

&gt; ```cpp
void AMyPlayerCharacter::GetLifetimeReplicatedProps(TArray&lt;FLifetimeProperty&gt;&amp; OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    // DOREPLIFETIME 함수의 인자로 (해당 Class - 변수 이름)을 넣는다.
    DOREPLIFETIME(AMyPlayerCharacter, PlayerHealth);
}</code></pre><ol start="5">
<li>Server에서 해당 변수를 업데이트한다.</li>
</ol>
<blockquote>
<pre><code class="language-cpp">void AMyPlayerCharacter::TakeDamage(int32 DamageAmount)
{
    if (HasAuthority())  // Ensure this is executed on the server
    {
        PlayerHealth -= DamageAmount;
        // If health falls below zero, the player dies
        if (PlayerHealth &lt;= 0)
        {
            Die();
        }
    }
}</code></pre>
</blockquote>
<pre><code>





</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[Sniper 구현]]></title>
            <link>https://velog.io/@ryan_ur/Sniper-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@ryan_ur/Sniper-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Fri, 30 Aug 2024 08:44:50 GMT</pubDate>
            <description><![CDATA[<p>슈팅 게임에서 빠질 수 없는 Sniper 무기를 구현해보자. 가장 기본적인 기능인 Sniper Aim 상태, Aim시 Scope 배율을 조정해서 조준경 확대, 마지막으로 발사까지 구현해보자. </p>
<h2 id="sniper-aim-모션">Sniper Aim 모션</h2>
<p>기존 개발하던 TPS 기반의 게임에서 Sniper Aim 상태로 전환하려면 어떻게 할지 고민을 좀 했었다. FPS였다면 총기 전환을 할때 원래 가지고 있었던 무기가 캐릭터 시점에서 없어진 시점 이후에는 그 무기의 행방이 랜더링 되지 않아 따로 처리해 주지 않아도 된다. 하지만 TPS에는 카메라가 캐릭터의 뒤까지 다 비추어주고 있어서 이 문제를 해결해야 했다. </p>
<p>일단 디테일한 부분까지는 잡지 못했지만, Idle 상태에서 Sniper를 Aim하고자 할때 원래 캐릭터를 비추고 있던 카메라의 <code>spring arm</code>의 길이와 카메라의 <code>socket offset</code>을 <code>Timeline</code>과 <code>Lerp</code>를 사용해 Rifle Firing을 구현했던 것처럼 만들었다. 또한, 이번에는 Aim시 카메라의 FOV까지 줄여 화면 확대를 더 표현해보고자 했다(FOV와 카메라의 확대 관계에 대해서는 밑에서 설명하도록 하겠다).  </p>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/fa2313e7-d859-487d-a2e3-777b8e1f9331/image.gif" alt=""></p>
<h2 id="render-target과-fov로-조준경-만들기">Render Target과 FOV로 조준경 만들기</h2>
<p>이제 Aim 모션을 만들었다면 저격총 렌즈를 통해 사물을 볼 수 있어야 한다. <a href="https://velog.io/@ryan_ur/%EB%AF%B8%EB%8B%88%EB%A7%B5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0">미니맵 구현</a>에서 했던 것처럼 Scene Capture Component 2D Camera를 사용해서 Render Target을 만들어보자.   </p>
<h4 id="scope에-보여질-material를-결정할-scene-capture-camera">Scope에 보여질 Material를 결정할 Scene Capture Camera</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/9079da50-73f3-4e53-9be3-d9f747172beb/image.png" alt=""></p>
<p>Sniper Rifle Blueprint를 만들어서 여기에 Sniper Rifle Skeletal Mesh를 Root로, 하위 자식으로는 Scope와 Camera를 배치한다. </p>
<h4 id="bp에서-다른-bp-attach-하는-방법">BP에서 다른 BP attach 하는 방법</h4>
<p>외부 BP를 캐릭터 BP에 다는 방법은 <code>Actor Component</code>를 사용해서 한다. Actor Component를 캐릭터 BP에 먼저 부착하고 이 Actor Component의 자식을 사용하고 싶은 외부 BP로 설정하면 된다. </p>
<p>만약, 이 캐릭터 BP에서 Actor Component의 자식으로 부착된 외부 BP에 접근하고 싶다면 Actor Component에서 Child Actor를 가져와 해당 BP로 Casting한다. Casting에서 반환된 오브젝트에서는 해당 BP의 함수, 변수들에 접근할 수 있다(Public으로 설정되어야 함을 명심!).</p>
<h2 id="확대-기능-만들기">확대 기능 만들기</h2>
<p>고정 배율을 가지고 있는 Sniper를 만들어도 되지만, 우리가 만들 Sniper는 보통 Sniper가 아니기 때문에 확대 기능까지 추가해 볼 것이다. 이를 위해서는 카메라가 사물을 어떻게 투영시키는지에 대한 기초적인 지식이 필요하다.  </p>
<h4 id="fov-focal-length-sensor-height의-관계">FOV, Focal Length, Sensor height의 관계</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/ae54974f-a350-4f8c-be38-c087ffe35f67/image.png" alt=""></p>
<p>카메라가 사물을 투영시키는 과정을 한번 봐야지 투영되는 상의 크기에 영향을 미치는 요인들을 알 수 있을 것이다. 우선 카메라가 찍고 있는 사물의 크기는 우리가 바꿀 수 없기 때문에 우리가 바꿀 수 있는 요인들을 살펴보자. </p>
<p>먼저, <code>렌즈의 시야(Field Of View)</code>가 있다. 직관적이게 FOV가 크면 넓은 화각이, FOV가 작으면 작은 화각이 나오게 된다. </p>
<p>두번째로는 <code>Focal length(초점 거리)</code>가 있다. 이는 렌즈와 센서 사이의 거리인데, 센서에 물체가 투영된다고 생각하면 된다. 같은 FOV라도 focal length가 짧으면 해당 물체가 그만큼 작게 보인다.  </p>
<p>FOV와 Focal Length, 투영된 사물의 크기의 관계식은 사진에서 보는 것처럼 tangent를 사용해서 표현 가능하다. </p>
<h4 id="마우스-scroll과-scene-capture-2d의-fov-mapping">마우스 Scroll과 Scene Capture 2D의 FOV mapping</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/e1affb0e-eda9-453d-931f-f0be144383c0/image.png" alt=""></p>
<p>왜인지는 모르겠지만 언리얼의 Scene Capture Camera에서는 Focal Length를 조절하는 부분을 찾지 못했다. 따라서 FOV를 조절하면서 배율 사이즈를 결정하기로 하였고 이에 대한 별도의 Input(마우스 scroll)을 만들었다. </p>
<p>이번에 <code>Scroll Input</code>을 처음 사용해 보았는데 신기하게도 위로 scroll 할 것인지 아래로 scroll 할 것인지 따로 정하지 않았는데 위로 scroll하면 BP에 적힌 로직이, 아래로 scroll하면 그 반대의 로직이 자동으로 실행되었다.  </p>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/9fec9f26-f082-4c25-89ee-aa024614a7cd/image.gif" alt=""></p>
<hr>
<h2 id="최종화면">최종화면</h2>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/1abde723-fb56-48b6-83eb-3d0224ac284b/image.gif" alt=""></p>
<p>조준하는 Aim 화면이 마음에 들지 않아서 Aim 되었을시 카메라의 FOV를 좀 더 낮추었고 사격시 데미지(Rifle의 보다 높게), 사격 효과와 카메라 shake까지 추가하였다. </p>
<p>화면상에서는 확인이 어렵지만 사격당한 홀로그램 적군은 안타깝게 즉사하였다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Enemy BT 만들기 ]]></title>
            <link>https://velog.io/@ryan_ur/Enemy-BT-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@ryan_ur/Enemy-BT-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Mon, 26 Aug 2024 13:12:16 GMT</pubDate>
            <description><![CDATA[<p>캐릭터와 무기에 대한 기능 구현이 어느 정도 완료되었으니 이제는 적군 AI를 만들어 볼 차례이다. 적군 AI을 만든다는 의미는 캐릭터가 [어떤 상태]에 [어떻게 행동] 할지를 규정한다는 것인데 바로 이 작업을 BT를 통해서 해결할 수 있다. </p>
<p>간단하게 BT를 설명하자면 트리 구조로 이루어져 있는 행동결정 알고리즘인데, 이 BT를 따르는 오브젝트가 주변 환경에 의해 하나의 상태로 진입하게 되면 그 상태에 해당하는 노드의 행동을 하도록 만드는 형식이다. </p>
<p>글을 더 읽기 전에, 아래 링크를 통해서 BT에 대한 기초 개념을 잡고 가면 좋을 것이다:) </p>
<p><a href="https://velog.io/@ryan_ur/2-11%EA%B0%95-%ED%96%89%EB%8F%99%ED%8A%B8%EB%A6%AC-%EB%AA%A8%EB%8D%B8%EC%9D%98-%EC%9D%B4%ED%95%B4">기본적인 BT 설명</a></p>
<p><a href="https://velog.io/@ryan_ur/2-12%EA%B0%95-%ED%96%89%EB%8F%99-%ED%8A%B8%EB%A6%AC-%EB%AA%A8%EB%8D%B8%EC%9D%98-%EA%B5%AC%ED%98%84">BT 심화</a></p>
<h1 id="기본적인-bt-설명">기본적인 BT 설명</h1>
<p>재미를 위해 적군의 종류를 3가지(Rifle, Shotgun, Rocket Launcher)로 만들었는데 데미지 방식과 사정거리 정도가 다르고 모두 같은 BT 로직을 따른다. </p>
<h4 id="전체-bt크게-patrol-attack-death-총-3가지-state">전체 BT(크게 Patrol, Attack, Death 총 3가지 State)</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/24fce222-43b6-41e2-9f16-1ffa9a6a2eca/image.png" alt=""></p>
<h4 id="patrol-상태">Patrol 상태</h4>
<p>적군은 <code>Perception AI</code>(시각을 주 감각기관으로 설정)에 자기와 다른 Tag를 가진 Character가 들어오면 이를 BlackBoard에 Target으로 결정하게 된다. 이 Target의 설정 여부를 체크하는 <code>Decorator</code>가 있는데 여기서 false값이 return되면 Patrol, true가 나오면 공격 State에 진입하도록 만든다. </p>
<p>Level에 Nav Mesh를 설치하게 되면, <code>GetRandomLocationNavigableRadius</code>라는 노드를 통해서 일정 반경 안에 있는 지점을 Random하게 정할 수 있다. 이를 적극 활용해서 &lt;Random한 지점 설정&gt; -&gt; &lt;그쪽으로 이동(<code>AI Move To</code>라는 노드를 언리얼에서 지원해준다. )&gt; -&gt; &lt;대기&gt; 의 사이클을 반복한다.  </p>
<h4 id="attack-상태">Attack 상태</h4>
<p>Target이 설정되었다면 이제 공격 상태로 진입한다. Target 설정 이외에 2개의 Decorator를 추가로 더 넣었는데, 둘 다 캐릭터가 죽는 모션 뒤에 바로 Destroy 안돼서 들어가게 되었다(이는 의도한 것인데, 캐릭터가 죽은 다음에 바로 없어지면 부자연스럽기 때문이다). </p>
<p><code>Self Not Dead</code> decorator는 자신의 생사 여부를 체크한다. 이를 통해 캐릭터가 죽으면 공격 모션을 멈출 수 있다. <code>EnemyAliveStatus</code> decorator는 죽은 적을 계속 공격하는 행동을 멈추기 위해 넣었다. </p>
<p>사실 이 부분에 핵심은 <code>Parallel</code> subtree에 있다. 여기서는 자신의 Target을 공격과 동시에 회전하는 부분이다. <code>Parallel</code> 노드의 왼쪽 자식을 메인 Task, 오른쪽 자식을 Background Task라 하는데 노드의 설정을 <code>Immediate</code>로 하면 메인 Task가 종료되자마자 <code>Parallel</code>을 빠져나오고 <code>Delayed</code>로 하면 Background Task가 끝날때까지 기다리게 된다. </p>
<p>왼쪽에 위치한 공격 Task에서는 무기 타입별로 다른 로직을 구현한다. 이에 대해서는 밑에서 더 설명하도록 하겠다. </p>
<p>Rotating Node에서는 적군 Actor의 회전 방향이 Target을 향하도록 설정한다. 여기에는 아주 유용한 노드들이 있는데 자신과 타겟의 Location Vector만 가지고 있으면 <code>Find Look at Rotation</code>을 통해 어디로 돌지 알 수 있고, <code>RInterp To</code>를 통해 부드럽게 회전, 최종적으로 <code>Set Actor Rotation</code>로 자신의 Rotation을 정한다. </p>
<p>사실상 Target의 위치는 계속 변하므로 Tick에서 이 로직을 처리해 주어야 하는데 이를 해결하는 방법은 아직 못찾아서 노드에 진입할시 실행되는 <code>Event Receive Execute AI</code>를 사용하였다. 내부 로직으로는 일정한 시간마다 event를 발동시키는 timer를 만든 다음에 custom event를 여기에 delegate로 등록시켜서 tick에서 처리하는 것처럼 만들어 보았다. </p>
<h4 id="death-상태">Death 상태</h4>
<p>자신이 받은 데미지를 <code>Event Any Damage</code>를 통해서 감지하고 있다가 일정량이 넘어가게 되면 자신의 상태를 죽었다라고 BlackBoard에 기록한다. <code>Self Dead</code> Decorator로 이를 확인하고 맞으면 RagDoll 상태로 만들고 Level에서 Destroy한다.  </p>
<hr>
<h1 id="캐릭터-종류">캐릭터 종류</h1>
<h4 id="shotgun">Shotgun</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/e27ed670-c7d6-4171-ab68-0923c9b04051/image.gif" alt=""></p>
<p>Shotgun은 무기 특성상 거리가 짧은 대신 한번 발사하면 넓은 범위를 타격하기 때문에 <code>Capsule Trace By Channel</code>로 collision 체크를 하고 맞은 대상에 대해서는 높은 데미지를 가하도록 하였다. </p>
<h4 id="rifle">Rifle</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/395acd19-69fe-4b30-9d11-080ade50645a/image.gif" alt=""></p>
<p>Rifle은 <code>Line Trace By Channel</code>로 collision 체크를 하였다. 불쌍한 AI가 허겁지겁 달려와 캐릭터를 향해 몇발 쏘고 사망하는 모습이다.</p>
<h4 id="rocket-launcher">Rocket Launcher</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/af4b4fed-ddc1-4407-9254-56f50e01a3a5/image.gif" alt=""></p>
<p>Rocket Launcher는 수류탄과 비슷하게 디자인했다. Rocket에 대한 별도의 BP를 만들어 공격시 Launcher muzzle 앞에 spawn이 된다. <code>Projectile Movement</code>, <code>Cascade Particle</code>, <code>Radial Force</code> 를 달아서 각각 spawn될때 초기 속도를 가지는 포물선 움직임, 연기 효과, 땅에 부딪혔을때 주변의 사물을 밀어내는 기능을 넣었다. </p>
<p>Rocket Launcehr 무기 특성상 가장 긴 사거리를 가지게 만들었다. <code>Move To</code> 노드에는 <code>Acceptance Radius</code>라는 필드가 있는데 Target에게 얼마나 가까이 접근할지에 대한 값이다. 위에서 설정한 <code>Projectile Movement</code>의 초기 속도 값과 <code>Acceptance Radius</code> 값을 서로 조절해가면서 어느 거리에서 어느 속도로 발사해야 Target이 피격될지에 대한 값을 구했다. </p>
<h4 id="rocket-발사체-bp">Rocket 발사체 BP</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/1c617aff-8106-404f-ad00-c056ce9080ea/image.png" alt=""></p>
<hr>
<h1 id="bt를-만들면서-해결했던-문제들">BT를 만들면서 해결했던 문제들</h1>
<p>*<em>1. 타겟이 자신으로부터 일정 거리이상 떨어졌어도 계속 추격하는 문제  *</em></p>
<p><strong>2. 자신이 공격한 타겟이 죽었음에도 해당 방향으로 계속 공격 모션이 되는 문제</strong> </p>
<p>*<em>3. 가장 멀리 있는 적군부터 타겟팅되는 문제 *</em></p>
<h3 id="1-타겟이-자신으로부터-일정-거리-이상-떨어졌어도-계속-추격하는-문제">1. 타겟이 자신으로부터 일정 거리 이상 떨어졌어도 계속 추격하는 문제</h3>
<p>언리얼의 Perception 시각 관련 AI에는 자신에 가시거리 안에 정보가 업데이트 될때 발생하는 event인 <code>On Target Perception Updated</code>와 반대로 가시거리 안에 있던 타겟이 없어지면 발생하는 이벤트인 <code>On Target Perception Forgotten</code>이 있다. 각각이 trigger 되는 거리는 AI Perception 디테일에서 조정 가능하다.  </p>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/d0d095c0-9943-4c11-a5e9-400fa7d68875/image.png" alt=""></p>
<p>다만 한가지 주의할 점은 블루프린트에서 이 <code>On Target Perception Forgotten</code>을 그냥 쓸 수 없다! 프로젝트 폴더 &gt; Config에는 <code>defaultengine.ini</code>라는 파일이 있는데 여기서 다음과 같은 텍스트를 추가해주어야 한다. </p>
<pre><code>[/Script/AIModule.AISystem]
bForgetStaleActors=True</code></pre><p>추가했다면, 타겟이 시야 반경에서 없어질때 <code>On Target Perception Forgotten</code> event가 들어오게 된다. <code>Clear Value</code>라는 노드로 BlackBoard에 존재했던 Target 값을 다시 초기화시켜준다. </p>
<h4 id="사정거리를-벗어나면-다시-patrol로-전환">사정거리를 벗어나면 다시 Patrol로 전환</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/a1088c2d-c8d3-4e0d-833f-cb0e16407975/image.gif" alt=""></p>
<h3 id="2-자신이-공격한-타겟이-죽었음에도-해당-방향으로-계속-공격-모션이-되는-문제">2. 자신이 공격한 타겟이 죽었음에도 해당 방향으로 계속 공격 모션이 되는 문제</h3>
<p>자연스러움을 위해서 캐릭터가 죽었을때 바로 없애지 않고 5초 정도 RagDoll 상태로 방치시킨 다음에 Level에서 Destroy하게 만들었다. 하지만 이 때문에 공격을 가하는 캐릭터 입장에서는 상대방이 죽어도 Target이 자신의 BlackBoard에 계속 남게 되어 공격 모션을 멈추지 않는 문제가 생겼다. </p>
<p>이를 위해서는 자신이 때리고 있은 Target의 BlackBoard에 접근해서 일정 시간마다 이 Target이 아직 살아있는지 확인하는 작업이 필요하다. 처음에는 Service 노드를 따로 만들어서 이를 해결하려고 했으나 특정 상황에서 모든 노드에 갈 수 없게 되면 BT자체가 멈춰 버린다는 상황에 직면하게 되었다(아래 그림에서 빨간색 줄이 그어진 박스들이 조건에 부합하지 않는 Decorator들이다.)  </p>
<h4 id="deadlock에-걸려서-bt가-멈춰버린-모습">Deadlock에 걸려서 BT가 멈춰버린 모습</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/89490aa0-fd75-437d-ba8c-d88a574e06d6/image.png" alt=""></p>
<p>그래서 정보를 얻어오는 로직을 캐릭터 BP에 구현하기로 하였다. Tick을 사용하면 너무 로드가 쎌 것 같아서 캐릭터의 BeginPlay에 <code>Set Timer By Event</code> 노드를 만들고 일정 시간마다 Delegate에 의해 발동되는 custom event를 만들었다. </p>
<h3 id="3-가장-멀리-있는-적군부터-타겟팅되는-문제">3. 가장 멀리 있는 적군부터 타겟팅되는 문제</h3>
<p>기존에 Target 세팅을 할때 <code>On Target Perception Updated</code>로 인식되는 Actor를 계속 새로운 Target으로 설정하였다. 하지만, 자신에게 가까운 적을 우선적으로 때려야지 먼 적을 우선적으로 타겟팅하는 것은 말도 안됨으로 이를 수정하는 작업을 하였다. </p>
<p>수정 작업은 매우 간단했다. Target 값이 없으면 <code>On Target Perception Updated</code>로 인지된 값을 Target으로 설정하고 이미 값이 있었으면 그냥 무시한다. </p>
<hr>
<h1 id="결과">결과</h1>
<h4 id="alpha-team-vs-bravo-team">Alpha Team VS Bravo Team</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/2e4d9213-45e8-4ed5-931a-cdcfb163bc01/image.png" alt=""></p>
<p>캐릭터의 TeamType을 결정하는 Enum을 하나 만들고 Alpha 혹은 Bravo 중 하나의 팀에 속하게 만들었다. </p>
<h4 id="fight-scene-1">Fight Scene 1</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/ed350eb7-0d5c-459d-98a8-173c9004c66d/image.gif" alt=""></p>
<h4 id="fight-scene-2">Fight Scene 2</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/7864c1fb-0810-4b14-b714-a2c8d0391d97/image.gif" alt=""></p>
<p>이 실험을 하면서 젤리처럼 생긴 우리 귀여운 캐릭터들을 너무 많은 죽여서 미안하다...</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[미니맵 구현하기]]></title>
            <link>https://velog.io/@ryan_ur/%EB%AF%B8%EB%8B%88%EB%A7%B5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@ryan_ur/%EB%AF%B8%EB%8B%88%EB%A7%B5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 19 Aug 2024 12:35:18 GMT</pubDate>
            <description><![CDATA[<h1 id="render-target으로-미니맵-구현하기">Render Target으로 미니맵 구현하기</h1>
<h4 id="perspective-projection-vs-orthogonal-projection">Perspective Projection VS Orthogonal Projection</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/b0ab4d6a-b4db-4123-86b0-677503e28efd/image.png" alt=""></p>
<p>시작에 앞서서 사영 종류에 대해 알아보자. 우리가 게임을 할때 컴퓨터가 3차원 공간에서 연산을 한다음에 최종적으로 2D Viewport에 사영하는 과정을 거친다. Computer Graphics의 Rendering Pipeline을 살펴보면 Clip Space에서 NDC로 물체를 사영시킬때 <code>Perspective Projection</code>에 의거하여 수학 연산을 하게 된다. 보통 대부분의 경우에 멀리 있는 물체가 작게 보이는 원근법(Perspective Projection)을 사용한다. </p>
<p>하지만 사영 방법에는 <code>Perspective Projection</code> 외에도 <code>Orthogonal Projection</code> 기법이 있는데, 이 기법은 물체의 거리와 무관하게 물체 순수 크기에 따라 사영 결과가 달라지게 된다. 미니맵은 물체가 상/하로 얼마나 이격되었는지에 대해 알고 싶은 것이 아닌 추상적인 지도의 개념이므로 <code>Perspective Projection</code>보다는 <code>Orthogonal Projection</code> 사영 방법이 더 적합하다.   </p>
<h2 id="render-target과-sprite의-콜라보">Render Target과 Sprite의 콜라보</h2>
<p>처음에 미니맵을 구현하려고 했을때 <code>Scene Capture Component</code>의 개념을 모르면 어떻게 구현하여야 할지 굉장히 막막했을 것이다. 반대로, Scene Capture와 Paper Sprite를 사용하게 되면 아주 간단하게 미니맵을 구현할 수 있다. </p>
<p>캐릭터에 수직 방향으로 Scene capture camera를 부착하고, 여기에 연동된 Render Target Texture를 Material로 가공해 UI에 보여주기만 하면 된다! </p>
<p>미니맵에는 Player Character가 그대로 나오면 이상하니 Scene capture camera와 Character 사이에 Character를 표현하는 Paper Sprite를 하나 넣어두어서 미니맵에 랜더링 되게 만든다. </p>
<h4 id="player-character-위에-paper-sprite-매달기">Player Character 위에 paper sprite 매달기</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/255985fb-5302-4513-8195-701ca73a7bfd/image.png" alt=""></p>
<p>캐릭터에 바로 Scene Capture Camera를 부착하면 Character Rotation에 따라 Camera가 같이 회전하게 된다. 이게 싫다면 캐릭터에 spring arm을 붙이고 이 spring arm에 Scene Capture Camera를 연결하자. 그런 다음, spring arm에서 Character <code>yaw 상속</code>을 해제하면 Camera는 항상 고정된 Rotation 값을 가지게 될 것이다. </p>
<h4 id="scene-capture-component-플래그-설정">Scene Capture Component 플래그 설정</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/28174c58-52a2-40ea-b779-20db867aebcc/image.png" alt=""></p>
<p>Scene Capture Component에는 아주 편리한 기능이 있는데, 이는 바로 플래그에서 카메라가 랜더딩할 요소들을 toggle 버튼으로 끄고 킬 수 있다는 점이다. Paper Sprite를 캐릭터 위에 매달아도 Scene Capture 카메라에 의해서 부분적으로 보일 수가 있는데 이를 플래그를 통해서 안보이게 만들 수 있다. </p>
<p>또 필자는, 미니맵에 그림자를 없애기 위해서 custom depth stencil을 통해 시도를 해보았으나 친절하게 언리얼에서 Scene Capture Component에 Dynamic Shadow에 대한 플래그 또한 준비를 해두었다. </p>
<p>다만 Scene Capture Component에 플래그를 설정하는 과정에서 언리얼 엔진의 고질적인 문제가 있는데 그것은 에디터를 시작한 다음에 플래그 관련 설정을 한번 껐다 켜주어야 한다는 사실이다. 계속 처음에 설정이 안되길래 검색을 해봤더니 나와 같은 문제를 겪는 사람들이 많았다.   </p>
<h4 id="flag-설정-x">Flag 설정 X</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/7bea72df-d70b-47dd-b96c-dcf6bea3936c/image.gif" alt=""></p>
<h4 id="skeletal-mesh-dynamic-shadow-flag-off">Skeletal Mesh, Dynamic Shadow Flag Off</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/5fb9da39-33be-49ef-9d44-377db6e59808/image.gif" alt=""></p>
<hr>
<h1 id="미니맵을-구현하는-2번째-방법">미니맵을 구현하는 2번째 방법</h1>
<p>미니맵을 Render Target으로 구현하면 비교적 쉽고 빠르게 만들 수 있지만, 매번 Scene Component로 Render Target Texture를 만들어야 한다. 이는 연산에 많은 로드가 걸리므로 지향하는 방법은 아니다.   </p>
<h2 id="texture위에-구현하기">Texture위에 구현하기</h2>
<p>다른 방법으로는 맵 자체를 스크린샷을 떠서 Player의 위치가 그 맵 위에 어디있는지 직접 계산하는 방법이 있다.   </p>
<h4 id="상단-뷰--언릿으로-맵-텍스쳐-가져오기">상단 뷰 &amp; 언릿으로 맵 텍스쳐 가져오기</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/6b356274-d07d-4730-8f3f-8f3b779aabca/image.png" alt=""></p>
<h4 id="character의-rotation에-따라-회전될-material">Character의 Rotation에 따라 회전될 Material</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/c64c41e7-1b4a-446c-8693-c286e995dc1b/image.png" alt=""></p>
<p>위 그림은 간단하게 Figma로 만들었다. 여기서 핵심은 밑에 맵이 보일 부분은 흰색, 나머지 회전될 부분은 검은색이라는 점이다. Opacity값에 이 Material과 맵을 곱하면 검은색 부분만 overlap 되어서 보일 것이다.  </p>
<h4 id="비율대로-uv-map에-곱해주기">비율대로 UV map에 곱해주기</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/8f5280b3-59bc-4638-b4ca-abe87de41262/image.png" alt=""></p>
<p>우리는 3000x3500 크기의 스크린샷 이미지를 1X1크기의 UV맵에 매핑시켜야 한다. 따라서 캐릭터가 레벨상에서 움직인 거리에 X축, Y축 각각 3000, 3500으로 나누는 계산이 필요하다.   </p>
<hr>
<h1 id="적군-marker를-미니맵-위에-표시">적군 Marker를 미니맵 위에 표시</h1>
<h4 id="적군과-main-character의-상대-위치-계산">적군과 Main Character의 상대 위치 계산</h4>
<p>적군 BP의 <code>BeginPlay</code>에 이들이 Spawn 될때 <code>Create Widget</code> -&gt; <code>Add To Viewport</code>를 통해서 자신을 표시하는 마커가 Main HUD위에 그려지게 한다. </p>
<p>자신의 위치에서 메인 캐릭터의 위치를 빼면 메인 캐릭터 입장에서의 상대적인 벡터가 나오게 되고 이를 통해 위젯으로 적군이 메인 캐릭터와 어느 각도로, 얼마나 멀리 떨어져 있는지 알 수 있다.</p>
<h4 id="범위를-벗어난-벡터는-clamp">범위를 벗어난 벡터는 Clamp</h4>
<p>이제 성공적으로 적군의 위치를 표시할 수 있게 되었다. 한 가지 문제점이 있다면, 이 방법은 너무 정직하게 적군에 대한 모든 정보를 다 알려 준다는 것이다. 적군이 너무 멀리 있으면 미니맵 범위를 뚫고 저 멀리 마커가 표시된다. 이를 방지하고자 미니맵 범위를 넘어가면 길이는 clamp해주고 방향만 표시해 주는 방법을 사용해보자.    </p>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/8fd43ca7-e730-45de-8f47-1b1523456faf/image.png" alt=""></p>
<p><code>Vector Length</code> 노드로 상대 Vector의 Norm을 가져오고, <code>Normalize 2D</code>라는 노드로 크기가 1인 순수 방향 벡터를 가져온다. <code>Vector Length</code>가 너무 멀면 아예 그리지 말고 어느 정도 멀다면 Clamp의 과정을 통해 미니맵의 가장자리에 걸치게 그려준다. </p>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/815528b0-0ba4-4427-b84e-5d7d8c8c8241/image.gif" alt=""></p>
<p>Clamp 범위를 벗어난 오브젝트들은 방향만, 캐릭터와 거리가 가까운 오브젝트들은 정확한 위치와 방향이 표시 가능한 미니맵이 탄생했다!</p>
<h2 id="material-parameter-collection">Material Parameter Collection</h2>
<p>일반 BP의 Event Graph에서는 외부에 있는 객체에 대해서 접근하는 다양한 방법이 존재한다. 하지만, Material은 지원하는 노드들이 대부분 Rendering에 관한 것들일 뿐 외부의 객체들에 대해서 알 수 있는 방법이 없다. 이를 위해서 <code>Material Parameter Collection</code>이라는 별도의 객체가 존재하는데 이것의 역할은 Material과 이 Material이 소통하고 싶은 외부 객체 중간에 위치해서 정보를 전달한다. </p>
<h4 id="mpc에-존재하는-scalar--vector-parameter-field">MPC에 존재하는 Scalar &amp; Vector Parameter Field</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/f587dbc2-676d-4b5f-b34a-db23138dc7eb/image.png" alt=""></p>
<p>MPC(Material Parameter Collection)을 생성하면, 변수를 등록하는 field가 있는데 이 변수가 scalar인지 vector인지 나누어서 등록을 해야한다. </p>
<hr>
<h1 id="다시-render-target으로">다시 Render Target으로...</h1>
<p>미니맵 시스템을 메인 프로젝트에 적용하려고 했는데 엄청난 문제에 직면하게 되었다. 맵 크기가 크다보니 맵 텍스쳐를 고해상도로 받을 수 없었다.  </p>
<h4 id="위에서-바라본-화성-기지">위에서 바라본 화성 기지</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/3eef7404-668f-49ac-ab88-f8f48ab56706/image.png" alt=""></p>
<h4 id="확대샷">확대샷</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/36248177-30d7-461b-8a0e-019bfafac702/image.png" alt=""></p>
<p>확대를 하니 답이 없는 수준으로 사진이 깨지게 되어서 어쩔 수 없이 Render Target을 사용하여서 최종 미니맵을 만들게 되었다. UI에 올려지는 적군 마커는 동일한 방법으로 상대위치 + clamping을 하여서 위젯에 올리게 만들었다. </p>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/a81442b2-6c5d-4889-a3e1-acd2d9666588/image.gif" alt=""></p>
<p>미니맵을 작업한 레벨에 올려보았다. 바닥을 Nanite로 만들었는데 Scene Capture에 이 flag가 꺼져있어서 다시 키는 과정을 거쳤다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AimOffset 구현하기(BlendSpace 사용 X, Bone의 회전으로!)]]></title>
            <link>https://velog.io/@ryan_ur/AimOffset-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0BlendSpace-%EC%82%AC%EC%9A%A9-x-Bone%EC%9D%98-%ED%9A%8C%EC%A0%84%EC%9C%BC%EB%A1%9C</link>
            <guid>https://velog.io/@ryan_ur/AimOffset-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0BlendSpace-%EC%82%AC%EC%9A%A9-x-Bone%EC%9D%98-%ED%9A%8C%EC%A0%84%EC%9C%BC%EB%A1%9C</guid>
            <pubDate>Sat, 17 Aug 2024 08:08:44 GMT</pubDate>
            <description><![CDATA[<h2 id="고민해봐야-할-질문">고민해봐야 할 질문</h2>
<p>총을 쏴서 적군에게 데미지를 가하는 기능을 만드려면 어떻게 해야할까? 어디서부터 시작해야할지 막막하다면 데미지를 주는 부분과 캐릭터의 시각적인 부분(Firing animation &amp; AimOffset)을 분리해서 차근차근 접근해보면 좋을 것 같다. 이번 글에서는 시각적인 부분을 중점적으로 다룰 것이다. </p>
<h3 id="데미지를-주는-로직">데미지를 주는 로직</h3>
<p>데미지를 주는 부분은 크게 2가지로 나누어 볼 수 있다. 첫 번째는 총알의 궤적에 대해 이에 맞은 물체와 collision을 구현하는 방법이다. <code>Trace By Channel</code> 노드와 이에 대한 시작점과 끝점 설정을 위한 <code>Control Rotation</code>에서 <code>Forward Vector</code>를 가져오는 방법을 알아야 한다. </p>
<p>두 번째는 실제로 데미지를 주는 부분인데, 총알을 쏘는 BP에서는 <code>Take Damage</code> 노드를 통해 얼마만큼의 데미지를 설정할지 정한다. 반대로 데미지를 받는 입장에서는 <code>Event Any Damage</code>를 통해 자신이 받은 데미지의 양 / 위치에 따라 다른 상태값이 되도록 설정하면 된다. 예를 들어서 데미지의 총량이 100을 넘어가면 RagDoll 상태로 만들 수 있고, 맞은 위치에 대해서는 머리를 맞으면 즉사, 그 외의 부분을 맞으면 소량의 데미지를 구현할 수 있다. </p>
<h3 id="시각적인-부분">시각적인 부분</h3>
<p>시각적인 부분에 대해 가장 먼저 떠오를 수 있는 것은 총알의 발사 위치이다. 현실에서는 총구(Muzzle)에서 총알이 나가는 것이 당연하지만 게임상(TPS라 가정)에서는 이렇게 구현하면 곤란한 점이 많다.</p>
<p>그 이유를 생각해보면 보통 총 게임이 실행될 때 조준선 UI를 만들어서 게임 화면 위에 띄워주게 된다. 그러면 당연히 플레이어들은 총알이 자신이 보고 있는 화면의 조준선(보통 화면의 정중앙)에 에임된다고 생각할 것이다. 이 조준선을 어떻게 구할지 생각해보면 플레이어가 들고 있는 총구의 방향이 아니라 화면을 찍고 있는 카메라의 상대적 위치로 하여 Forward Vector를 더하면 그 궤적이 나올 것을 생각해 볼 수 있다.</p>
<ul>
<li><h4 id="화면에-overlay로-띄워주는-총구-조준선">화면에 Overlay로 띄워주는 총구 조준선</h4>
<img src="https://velog.velcdn.com/images/ryan_ur/post/f6e50f97-e177-4bac-9198-64655dd78233/image.png" alt=""></li>
</ul>
<p>두 번째는 캐릭터 Bone의 회전이다. TPS에서는 캐릭터가 화면상에 어느 위치를 보고 있느냐에 따라 몸의 회전이 같이 일어나야 한다. 예를 들어서 위를 보고 있으면 척추와 목을 위로, 아래를 보고 있으면 아래로 꺾어 주어야 한다. 마찬가지로 발이 고정되어 있는 상태에서 좌우를 보는 모션을 준다면 얼굴 방향을 척추와 목을 바라보고 있는 방향으로 틀어주어야 한다. </p>
<p>잘 준비된 애니메이션 에셋 같은 경우는 이미 캐릭터가 다른 각도를 바라보고 있는 애니메이션 시퀀스가 준비되어 있다. 이러한 경우에는 Player의 Control Rotation의 값들을 축으로 하는 BlendSpace를 만들어서 AnimGraph에 넣어주면 된다. 하지만, 일반 애니메이션의 경우에는 이렇지 않으므로 우리는 직접적으로 Skeletal Mesh Bone의 회전값을 조정하면서 AimOffset을 만들어 볼 것이다.   </p>
<hr>
<h2 id="bone의-회전으로-aimoffset-구현하기">Bone의 회전으로 AimOffset 구현하기</h2>
<p>Skeletal Mesh에서 Bone의 회전을 주는 방법에는 크게 2가지가 있는 것 같다. 하나는 AnimGraph의 <code>Transform Bone</code> 노드를 사용하는 것이고 다른 하나는 <code>Control Rig</code>을 사용하는 것이다. <code>Control Rig</code>을 사용하면 별도의 클래스를 만들어야 하지만 <code>Transfrom Bone</code>보다 저 세밀한 컨트롤을 할 수 있다는 장점이 있다. </p>
<p>위/아래 AimOffset 같은 경우는 Skeletal Mesh의 거의 최상단 뼈인 <code>spine_01</code>만 조절하므로 <code>Transform Bone</code>을 사용할 것이고, 좌/우 AimOffset 같은 경우는 spine, neck, head의 여러 Bone을 회전시켜야 자연스러움으로 <code>Control Rig</code>을 사용해 볼 것이다. </p>
<h3 id="상하-aimoffset">상/하 AimOffset</h3>
<p><code>Transform Bone</code>으로 spine rotation 값을 조절해보면서 상/하 AimOffset을 만들어보자. Player Control Rotation에 대해서 캐릭터의 spine 회전값을 얼마나 조절할지는 카메라의 forward vector의 방향과 총열의 방향을 비교해보면 된다. 이를 맞춰주면 플레이어 입장에서 자신이 총을 쐈을때 총알이 마치 총구에서 발사 시작되어 화면 정중앙에 있는 조준선으로 향하는 느낌을 받을 수 있기 때문이다.</p>
<p>이를 직접적으로 확인해보기 위해서 다양한 높이에 Dummy Target을 배치한 다음 테스트를 해보자.  </p>
<h4 id="다양한-높이로-서있는-dummy-target">다양한 높이로 서있는 Dummy Target</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/c208c928-3ecf-45de-87f1-78ec05db69ea/image.png" alt=""></p>
<p>Target이 낮은 높이에 있을 때 Aim시와, Target이 높은 높이에 있을때 Aim시 방향이 잘 맞추어 진것을 확인할 수 있다.  </p>
<ul>
<li><h4 id="낮은-높이-aim">낮은 높이 Aim</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/e99ca5c5-3911-4902-9211-d9fca7379735/image.gif" alt=""></p>
</li>
<li><h4 id="높은-높이-aim">높은 높이 Aim</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/230b5fd7-5bac-403c-879d-7ac4375a81d4/image.gif" alt=""></p>
</li>
</ul>
<p>아래 그림은 AnimGraph에 넣은 Transform Bone Node이다. Control Rotation값에 알맞게 scaling 된 값을 이 노드의 Rotation 핀에 연결한다. </p>
<h4 id="animgraph에서-transform-bone-node">AnimGraph에서 Transform Bone Node</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/fae40770-52f2-4996-87db-5ba069acc4a3/image.png" alt=""></p>
<p>상/하 AimOffset에서 또 하나 고려해야 할 점이 있는데, 바로 카메라의 위치에 따라서 Rotation Scale 값을 한번 더 수정해 주어야 한다는 것이다. Aim을 하지 않았을때는 카메라가 멀리서 캐릭터를 찍고 있고, Aim을 했을때는 가까이서 찍고 있기 때문이다. </p>
<h4 id="aim-x시-scale-값과-aim-o시-scale-값-다르게-조정">Aim X시 scale 값과 Aim O시 scale 값 다르게 조정</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/2638d4a3-aa3c-4c61-b297-6ed770c0d0eb/image.png" alt=""></p>
<p>캐릭터 Animation Blueprint의 Event Graph에서 이 2가지 경우에 대해 각각 다른 회전값을 설정한다. 조준시에는 Control Rotation의 변화에 대해 척추 회전에 적게 들어가야 하므로 0.05로 scaling factor를 설정했고, 조준 X시에는 scaling factor를 1로 설정하였다. </p>
<h4 id="조준-x시-척추-bone의-pitch-방향-회전">조준 X시 척추 bone의 pitch 방향 회전</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/cce3fcd9-2660-446c-a410-594dd992569f/image.gif" alt=""></p>
<h4 id="조준-o시-척추-bone의-pitch-방향-회전">조준 O시 척추 bone의 pitch 방향 회전</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/1929f89d-41b0-40a3-b784-4cd59b498fa2/image.gif" alt=""></p>
<hr>
<h3 id="좌우-aimoffset">좌/우 AimOffset</h3>
<p>좌/우 AimOffset 같은 경우에는 더 정밀한 본의 회전을 위해 Control Rig을 사용해보자. Control Rig은 Transform Bone과 다르게 여러개의 Bone을 한꺼번에 조절 가능해서 훨씬 더 정밀한 애니메이션을 구현할 수 있다.</p>
<h4 id="control-rig에서-여러개의-본-설정">Control Rig에서 여러개의 본 설정</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/addb81e1-9a4d-4cc2-b36f-c9a2f3cd387f/image.png" alt=""></p>
<p>좌/우 회전시 Spine이 Neck과 Head의 Bone들보다 더 회전이 크므로 높은 scaling factor를 부여하였다.  </p>
<h4 id="control-rig과-회전-변수-연결">Control Rig과 회전 변수 연결</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/1fccfd27-013c-4909-a412-1bc80af8a9d3/image.png" alt=""></p>
<p>Control Rig 클래스에서 어떤 Bone들을 어떤 scaling factor로 조절할 지 정하였으면 이를 Anim Graph에서 연결하는 과정이 필요하다. </p>
<h4 id="event-graph에서-좌우-회전-각도-clamp">Event Graph에서 좌/우 회전 각도 Clamp</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/6ca0e8e7-a06e-4c0a-b3df-8b9ad5e962cc/image.png" alt=""></p>
<p>사람이라면 아무리 노력한다 하더라도 허리가 360도 돌아가는 일이 없을 것이다. 따라서 Rotation값에 일정 범위에 대한 Clamping 과정이 필요하다. </p>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/7a336fa3-9cfc-4c5b-8419-5656ad3e2f15/image.gif" alt=""></p>
<p>Control Rotation(조준선의 위치로 파악 가능)이 좌/우 방향으로 더 간다 하더라도 캐릭터의 Spine/Neck/Head의 Rotation은 일정 범위에 도달하면 더 이상 움직이지 않는다. </p>
<hr>
<h2 id="reference">Reference</h2>
<p><a href="https://www.youtube.com/@GorkaGames">참고 유튜브</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[수류탄 기능 구현하기]]></title>
            <link>https://velog.io/@ryan_ur/Radial-Force%EB%A1%9C-%EC%88%98%EB%A5%98%ED%83%84-%EB%8D%B0%EB%AF%B8%EC%A7%80-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@ryan_ur/Radial-Force%EB%A1%9C-%EC%88%98%EB%A5%98%ED%83%84-%EB%8D%B0%EB%AF%B8%EC%A7%80-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 14 Aug 2024 08:34:54 GMT</pubDate>
            <description><![CDATA[<p>저번에는 수류탄이 Spawn되는 Location 및 Rotation을 Player Controller Rotation에 따라 다르게 잡는 것을 해보았다. 이제는 실제로 수류탄이 Character 주변에 떨어졌을때 Damage를 가하는 작업을 해보자. </p>
<h2 id="apply-damage-vs-apply-radial-damage">Apply Damage VS Apply Radial Damage</h2>
<p>Rifle을 발사했을때 데미지 이펙트를 가하는 로직은 <code>LineTraceByChannel</code>을 사용해서 Hit된 result에 <code>Apply Damage</code>를 통해 데미지를 가하는 방식이였다. 하지만 수류탄의 경우에는 수류탄이 떨어진 지점을 기준으로 구 형태의 데미지를 주어야 한다. 따라서 <code>Apply Radial Damage</code> 노드를 사용한다.  </p>
<p>하지만, <code>Apply Damage</code>와 <code>Apply Radial Damage</code>에는 사용법에서 다른 점이 있다. 가장 큰 차이점은 <code>Apply Damage</code>는 어떠한 대상에게 데미지를 줄지 정해준다면 <code>Apply Radial Damage</code>는 어떠한 대상에게 데미지를 제외시킬지를 정한다.  </p>
<h4 id="apply-damage에서-데미지-가할-엑터-설정">Apply Damage에서 데미지 가할 엑터 설정</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/56a982cb-1867-4a59-b061-3ac9b2a5b5b4/image.png" alt=""></p>
<p>기존 Rifle에서는 Apply Damage의 Damaged Actor 핀에 Trace Channel에 의해 감지된 Actor를 연결하였다.  </p>
<h4 id="apply-radial-damage에서-데미지-제외할-엑터-설정">Apply Radial Damage에서 데미지 제외할 엑터 설정</h4>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/e24a3be4-5c84-4eb7-828e-27623ca5233f/image.png" alt=""></p>
<p>Apply Radial Damage는 Ignore Actors에 데미지를 제외할 엑터를 지정할 수 있다(지정하지 않으면 Damage Radius안의 모든 엑터에게 데미지 수행).</p>
<hr>
<h2 id="apply-radial-damage의-중요-핀-분석">Apply Radial Damage의 중요 핀 분석</h2>
<ul>
<li><h4 id="base-damage">Base Damage</h4>
<p>Base Damage는 반경 내 모든 엑터에게 가해질 기본 피해량이다. 중심으로부터 데미지거리 감쇠에 해당하는 <code>Do Full Damage</code>를 false로 체크해 놓으면 반경 내 엑터들에게 동일한 데미지를 가한다. </p>
</li>
<li><h4 id="damage-radius">Damage Radius</h4>
<p>피해가 적용될 반경</p>
</li>
<li><h4 id="origin">Origin</h4>
<p>Radial Damage 구의 중심점</p>
</li>
<li><h4 id="do-fulldamage">Do FullDamage</h4>
<p>이 핀을 True로 설정하면 범위 안 엑터들에게 모두 동일한 데미지를 가한다. False로 지정하면 중심으로부터 구의 끝 표면까지 데미지가 거리에 따라 감쇠적으로 들어가게 된다. </p>
</li>
<li><h4 id="damage-prevention-channel">Damage Prevention Channel</h4>
<p>필자는 이 핀 때문에 많은 고생을 했다. 이 핀의 설정을 통해 폭발의 중심과 엑터사이에 가로막는 물체에 의해 데미지를 받을 지 안 받을지를 설정할 수 있다. 수류탄을 던졌는데 만약 엑터가 반경안에 있다 할지라도 벽 뒤에 숨어있으면 데미지를 안받는 상황을 이 핀을 통해 재현할 수 있다. </p>
<p>Default 값으로는 Visibility Channel이 설정 되어있는데, 이를 WorldStatic Channel로 바꾸니 문제가 해결되었다(필자는 폭발안에 있는 dummy 엑터의 collision preset을 BlockAll로 설정).  </p>
</li>
</ul>
<hr>
<h2 id="radial-force">Radial Force</h2>
<p>Apply Radial Damage로 데미지를 가하는 부분이 있다면 이제 물리적으로 데미지가 가해진 엑터를 튕겨내는 효과가 필요하다. 시각적으로 생각한다면, 중심을 기준으로 점점 커지는 sphere collision을 만들고 없어지는 물체를 만든다고 생각해보자. 처음에는 Radial Force와 Apply Radial Damage가 헷갈릴 수 있으니 둘의 차이를 명확히 이해해보자.  </p>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/98e643ac-eca7-4f06-9d22-81d22d2b7765/image.png" alt=""></p>
<p>Radial Force의 여러 property를 하나씩 바꿔가면서 알맞은 효과를 찾아보자. 방사형 포스 컴포넌트의 반경, 임펄스의 세기, 포스의 세기 강제의 속성 값들을 조절하면서 이펙트를 결정하였다. </p>
<hr>
<h2 id="ragdoll">RagDoll</h2>
<p>캐릭터가 죽으면 Set Simulate Physic을 활성화시켜 RagDoll 모션을 추가해봤다. AnimGraph에서 죽는 애니메이션을 만들어 추가해도 되지만, 간편하게 다양한 죽는 자세를 만드는데는 RagDoll은 정말 획기적인 기능이다.    </p>
<hr>
<h2 id="최종-화면">최종 화면</h2>
<p><img src="https://velog.velcdn.com/images/ryan_ur/post/8ff7bc17-418e-49d9-8264-ab14c1740be3/image.gif" alt=""></p>
<p>캐릭터의 체력을 100으로 설정하고 수류탄의 중심점의 데미지를 200으로 설정하였다. 폭발 가운데 지점의 더미 엑터들은 사망하여서 RagDoll 상태로 변하였고 주변 더미 엑터들은 거리에 따른 데미지 감쇠효과를 받아 살아있는 모습을 확인할 수 있다. </p>
<p>수류탄이 터질때 나타나는 빨간색 구는 DrawDebugSphere로 만든 Apply Radial Damage의 영역이다. 캐릭터는 RagDoll이 된후 10초가 지나면 Destory하게 만들었다.  </p>
]]></description>
        </item>
    </channel>
</rss>