<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>서보훈_코딩공부 블로그</title>
    <link>https://gamerscode.tistory.com/</link>
    <description>스파르타 코딩클럽 개임개발 Unity 공부 블로그입니다.</description>
    <language>ko</language>
    <pubDate>Fri, 31 Jul 2026 19:06:15 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>서보훈</managingEditor>
    <item>
      <title>리소스 매니저 소스코드</title>
      <link>https://gamerscode.tistory.com/152</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Resources 에서 불러오기를 당담하는 매니저&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1743046959128&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class ResourceManager : PersistentSingleton&amp;lt;ResourceManager&amp;gt;
{
    //불러온 리소스를 저장
    private Dictionary&amp;lt;string,object&amp;gt; resourcePool = new Dictionary&amp;lt;string,object&amp;gt;();

    public T LoadResource&amp;lt;T&amp;gt;(string name, EMajorType majorType, ESubType subType = ESubType.None)
    {
        StringBuilder stringBuilder = new StringBuilder();

        T returnAsset;

        stringBuilder.Append(majorType);
        stringBuilder.Append(subType == ESubType.None ? &quot;&quot; : $&quot;/{subType}&quot;);
        stringBuilder.Append($&quot;/{name}&quot;);

        if (!resourcePool.ContainsKey(stringBuilder.ToString()))
        {
            var targetResource = Resources.Load(stringBuilder.ToString(), typeof(T));

            if(targetResource == null )
            {
                return default;
            }

            resourcePool.Add(stringBuilder.ToString(), targetResource);
        }

        returnAsset = (T)resourcePool[stringBuilder.ToString()];
        return returnAsset;
    }

    public List&amp;lt;T&amp;gt; LoadAllResources&amp;lt;T&amp;gt; (EMajorType majorType, ESubType subType = ESubType.None)
    {
        StringBuilder stringBuilder = new StringBuilder();

        stringBuilder.Append(majorType);
        stringBuilder.Append(subType == ESubType.None ? &quot;&quot; : $&quot;/{subType}&quot;);

        List&amp;lt;T&amp;gt; returnResources = new List&amp;lt;T&amp;gt;();

        if (!resourcePool.ContainsKey(stringBuilder.ToString()))
        {
            List&amp;lt;string&amp;gt; keyList = new List&amp;lt;string&amp;gt;();

            object[] loaded = Resources.LoadAll(stringBuilder.ToString(), typeof(T));


            foreach (object loadedObj in loaded)
            {
                StringBuilder key = new StringBuilder();
                key.Append(stringBuilder.ToString());
                key.Append($&quot;/&quot;);
                key.Append(loadedObj.ToString());
                key.Replace($&quot; ({loadedObj.GetType()})&quot;, &quot;&quot;);

                resourcePool.Add(key.ToString(), loadedObj);

                keyList.Add(key.ToString());
            }

            resourcePool.Add(stringBuilder.ToString(), keyList);
        }

        List&amp;lt;string&amp;gt; getKey = (List&amp;lt;string&amp;gt;)resourcePool[stringBuilder.ToString()];

        for(int i = 0; i &amp;lt; getKey.Count; i++)
        {
            returnResources.Add((T)resourcePool[getKey[i]]);
        }

        return returnResources;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;--- 비동기 방식&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동기 방식 내용 추가할것&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Enum의 경우, Resources 폴더의 하위 폴더에 맞추어서 추가해주기&lt;/p&gt;
&lt;pre id=&quot;code_1743046966609&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public enum EMajorType
{
    Data,
    Prefab
}

public enum ESubType
{
    UI,
    Item,
    Character,
    None
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>유니티 소스코드</category>
      <author>서보훈</author>
      <guid isPermaLink="true">https://gamerscode.tistory.com/152</guid>
      <comments>https://gamerscode.tistory.com/152#entry152comment</comments>
      <pubDate>Thu, 27 Mar 2025 12:43:28 +0900</pubDate>
    </item>
    <item>
      <title>SFM(유한상태기계) 소스코드</title>
      <link>https://gamerscode.tistory.com/151</link>
      <description>&lt;pre id=&quot;code_1742947194424&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public abstract class State
{
    //현재 상태를 관리하는 유한상태기계
    protected StateMachine stateMachine;
    //상태를 가지는 캐릭터
    protected Character character;

    public State(StateMachine stateMachine, Character character)
    {
        this.stateMachine = stateMachine;
        this.character = character;
    }

    public abstract void OnEnter();

    public abstract void OnExit();

    public abstract void OnUpdate();

    public abstract void OnFixedUpdate();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1742947203678&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class StateMachine
{
    //현재 상태
    private State currentState;
    public State CurrentState =&amp;gt; currentState;

    public void ChangeState(State newState)
    {
        //상태 변경
        currentState?.OnExit();
        currentState = newState;
        currentState?.OnEnter();
    }

    public void StateUpdate()
    {
        currentState?.OnUpdate();
    }

    public void StateFixedUpdate()
    {
        currentState.OnFixedUpdate();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용시 상속하여 대응되는 상태, FSM 을 만들어서 사용&lt;/p&gt;
&lt;pre id=&quot;code_1742947226579&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class EnemyIdleState : State
{
    public EnemyIdleState(StateMachine stateMachine, Character character) : base(stateMachine, character)
    {
    }

    public override void OnEnter()
    {
        
    }

    public override void OnExit()
    {
        
    }

    public override void OnFixedUpdate()
    {
        
    }

    public override void OnUpdate()
    {
        
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1742947260694&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class EnemyStateMachine : StateMachine
{
    public EnemyIdleState idleState {  get; private set; }

    public EnemyStateMachine(Character enemy)
    {
        idleState = new EnemyIdleState(this, enemy);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐릭터 또한 기반 캐릭터를 상속시켜서 사용하는것이 좋은&lt;/p&gt;
&lt;pre id=&quot;code_1742947278122&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class Character : MonoBehaviour
{
    private EnemyStateMachine stateMachine;

    private void Awake()
    {
        stateMachine = new EnemyStateMachine(this);
    }
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>유니티 소스코드</category>
      <author>서보훈</author>
      <guid isPermaLink="true">https://gamerscode.tistory.com/151</guid>
      <comments>https://gamerscode.tistory.com/151#entry151comment</comments>
      <pubDate>Wed, 26 Mar 2025 09:01:23 +0900</pubDate>
    </item>
    <item>
      <title>싱글톤 패턴 소스코드</title>
      <link>https://gamerscode.tistory.com/150</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;기반, 해당씬에서만 유지되는 싱글톤&lt;/p&gt;
&lt;pre id=&quot;code_1742778817908&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class Singleton&amp;lt;T&amp;gt; : MonoBehaviour where T : Singleton&amp;lt;T&amp;gt;
{
    private static T instance;
    public static T Instance
    {
        get
        {
            if(instance == null)
            {
                instance = new GameObject(typeof(T).Name).AddComponent&amp;lt;T&amp;gt;();
            }

            return instance;
        }
    }

    public static bool InstanceExist { get { return instance != null; } }

    protected virtual void Awake()
    {
        if(InstanceExist)
        {
            Destroy(gameObject);
            return;
        }

        instance = (T)this;
    }

    protected virtual void OnDestroy()
    {
        if(instance == this)
        {
            instance = null;
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;씬을 넘어가는 싱글톤&lt;/p&gt;
&lt;pre id=&quot;code_1742778832586&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class PersistentSingleton&amp;lt;T&amp;gt; : Singleton&amp;lt;T&amp;gt; where T : Singleton&amp;lt;T&amp;gt;
{
    protected override void Awake()
    {
        base.Awake();
        DontDestroyOnLoad(gameObject);
    }
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>유니티 소스코드</category>
      <author>서보훈</author>
      <guid isPermaLink="true">https://gamerscode.tistory.com/150</guid>
      <comments>https://gamerscode.tistory.com/150#entry150comment</comments>
      <pubDate>Mon, 24 Mar 2025 10:14:22 +0900</pubDate>
    </item>
    <item>
      <title>프레임워크 - 리소스 매니저</title>
      <link>https://gamerscode.tistory.com/149</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Resources 폴더에서 프리팹이나 SO 등의 데이터를 가져오는 역할을 하는 매니저 입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 enum 형태로 리소스 폴더의 대분류, 소분류를 만들어주었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소분류의 경우 사용하지 않는 경우도 있기때문에 None 을 만들어주었습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1738566162963&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//파일 경로 (대분류)
public enum EMajorType
{
    Data,
    Prefab,
    Sound
}

//파일 경로 (세부)
public enum ESubType
{
    None,
    UI
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷(1459).png&quot; data-origin-width=&quot;183&quot; data-origin-height=&quot;103&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/swBSN/btsL5zICnbX/0WqxXyBe0IHxnKSB1j3xGK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/swBSN/btsL5zICnbX/0WqxXyBe0IHxnKSB1j3xGK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/swBSN/btsL5zICnbX/0WqxXyBe0IHxnKSB1j3xGK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FswBSN%2FbtsL5zICnbX%2F0WqxXyBe0IHxnKSB1j3xGK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;183&quot; height=&quot;103&quot; data-filename=&quot;스크린샷(1459).png&quot; data-origin-width=&quot;183&quot; data-origin-height=&quot;103&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 임시로 만들어준 상태입니다. 리소스 폴더의 내용이 늘어날때, enum 의 종류도 늘려주면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;싱글톤의 경우, 별도로 만들어주어야합니다.&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ResourceManager&lt;/p&gt;
&lt;pre id=&quot;code_1738566280655&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//리소스 파일에서 데이터를 가져오는 클래스
public class ResourceManager : PersistentSingleton&amp;lt;ResourceManager&amp;gt;
{
    private Dictionary&amp;lt;string, object&amp;gt; resourcePool = new Dictionary&amp;lt;string, object&amp;gt;();

    /// &amp;lt;summary&amp;gt;
    /// 동기로 리소스 불러오기, key : 불러올 에셋 이름, majorType : 대분류, subType : 소분류 비어있을경우 None 으로 처리
    /// &amp;lt;/summary&amp;gt;
    /// &amp;lt;typeparam name=&quot;T&quot;&amp;gt;&amp;lt;/typeparam&amp;gt;
    /// &amp;lt;param name=&quot;key&quot;&amp;gt;&amp;lt;/param&amp;gt;
    /// &amp;lt;param name=&quot;type&quot;&amp;gt;&amp;lt;/param&amp;gt;
    /// &amp;lt;param name=&quot;categoryType&quot;&amp;gt;&amp;lt;/param&amp;gt;
    /// &amp;lt;returns&amp;gt;&amp;lt;/returns&amp;gt;
    public T LoadResource&amp;lt;T&amp;gt; (string key, EMajorType majorType, ESubType subType = ESubType.None)
    {
        //리소스 폴더 경로 저장
        StringBuilder stringBuilder = new StringBuilder();

        //반환할 객체용
        T returnAsset;

        //경로 생성
        stringBuilder.Append(majorType);
        stringBuilder.Append((subType == ESubType.None) ? &quot;&quot; : $&quot;/{subType}&quot;);
        stringBuilder.Append($&quot;/{key}&quot;);

        //딕셔너리에서 같은 에셋이 있는지 검색
        if(!resourcePool.ContainsKey(stringBuilder.ToString()))
        {
            var targetResource = Resources.Load(stringBuilder.ToString(), typeof(T));

            //대상이 없을경우 처리
            if(targetResource == null )
            {
                return default;
            }

            resourcePool.Add(stringBuilder.ToString(), targetResource);
        }

        //반환 에셋 지정
        returnAsset = (T)resourcePool[stringBuilder.ToString()];

        return returnAsset;
    }

    /// &amp;lt;summary&amp;gt;
    /// 비동기로 리소스 불러오기, key : 불러올 에셋 이름, majorType : 대분류, subType : 소분류 비어있을경우 None 으로 처리
    /// &amp;lt;/summary&amp;gt;
    /// &amp;lt;typeparam name=&quot;T&quot;&amp;gt;&amp;lt;/typeparam&amp;gt;
    /// &amp;lt;param name=&quot;key&quot;&amp;gt;&amp;lt;/param&amp;gt;
    /// &amp;lt;param name=&quot;majorType&quot;&amp;gt;&amp;lt;/param&amp;gt;
    /// &amp;lt;param name=&quot;subType&quot;&amp;gt;&amp;lt;/param&amp;gt;
    /// &amp;lt;returns&amp;gt;&amp;lt;/returns&amp;gt;
    public async Task&amp;lt;T&amp;gt; LoadResourceAsync&amp;lt;T&amp;gt;(string key, EMajorType majorType, ESubType subType = ESubType.None)
    {
        StringBuilder stringBuilder = new StringBuilder();

        T returnAsset;

        stringBuilder.Append(majorType);
        stringBuilder.Append((subType == ESubType.None) ? &quot;&quot; : $&quot;/{subType}&quot;);
        stringBuilder.Append($&quot;/{key}&quot;);

        if (!resourcePool.ContainsKey(stringBuilder.ToString()))
        {
            //비동기로 불러오기
            var op = Resources.LoadAsync(stringBuilder.ToString(), typeof(T));

            //처리가 완료될때까지 대기
            while(!op.isDone)
            {
                await Task.Yield();
            }

            var targetResource = op.asset;

            if(targetResource == null )
            {
                return default;
            }

            resourcePool.Add(stringBuilder.ToString(), targetResource);
        }

        returnAsset = (T)resourcePool[stringBuilder.ToString()];

        return returnAsset;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동기와 비동기로 리소스를 가져오는 메서드를 나누어주었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동기의 경우, 씬을 로드할 때 사용하여 필요한 내용을 미리 로드하기 위해 사용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비동기의 경우 게임 진행중에 로드가 필요할경우 사용하게 될 것 입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 이미 로드한 리소스를 다시 로드하지 않기 위해, 딕셔너리로 캐싱하는 방식을 사용하였습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1738566405315&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private Dictionary&amp;lt;string, object&amp;gt; resourcePool = new Dictionary&amp;lt;string, object&amp;gt;();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론, 캐싱한 데이터가 많아질경우의 문제도 존재하기 때문에 적절한 위치에서 데이터를 비워줄 필요가 있을것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 문제는 차후에 생각해볼 예정입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동기 형태로 로드하는 메서드 입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1738566478770&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public T LoadResource&amp;lt;T&amp;gt; (string key, EMajorType majorType, ESubType subType = ESubType.None)
{
    //리소스 폴더 경로 저장
    StringBuilder stringBuilder = new StringBuilder();

    //반환할 객체용
    T returnAsset;

    //경로 생성
    stringBuilder.Append(majorType);
    stringBuilder.Append((subType == ESubType.None) ? &quot;&quot; : $&quot;/{subType}&quot;);
    stringBuilder.Append($&quot;/{key}&quot;);

    //딕셔너리에서 같은 에셋이 있는지 검색
    if(!resourcePool.ContainsKey(stringBuilder.ToString()))
    {
        var targetResource = Resources.Load(stringBuilder.ToString(), typeof(T));

        //대상이 없을경우 처리
        if(targetResource == null )
        {
            return default;
        }

        resourcePool.Add(stringBuilder.ToString(), targetResource);
    }

    //반환 에셋 지정
    returnAsset = (T)resourcePool[stringBuilder.ToString()];

    return returnAsset;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리소스 폴더의 경로는 string 값으로 지정되며, 이 값을 만들어주기 위해 StringBuilder 를 사용해주었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;딕셔너리에 같은 리소스가 있는지 경로 이름으로 key를 검색하며, 없을경우 경로를 key로 사용하여 리소스를 저장합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;딕셔너리에 저장이 완료되거나, 경로가 딕셔너리에 있을경우 저장된 리소스를 반환합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비동기 방식의 경우, LoadAsync 를 사용하는 내용이 추가되었을 뿐, 크게 다른 내용은없습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1738566787620&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public async Task&amp;lt;T&amp;gt; LoadResourceAsync&amp;lt;T&amp;gt;(string key, EMajorType majorType, ESubType subType = ESubType.None)
{
    StringBuilder stringBuilder = new StringBuilder();

    T returnAsset;

    stringBuilder.Append(majorType);
    stringBuilder.Append((subType == ESubType.None) ? &quot;&quot; : $&quot;/{subType}&quot;);
    stringBuilder.Append($&quot;/{key}&quot;);

    if (!resourcePool.ContainsKey(stringBuilder.ToString()))
    {
        //비동기로 불러오기
        var op = Resources.LoadAsync(stringBuilder.ToString(), typeof(T));

        //처리가 완료될때까지 대기
        while(!op.isDone)
        {
            await Task.Yield();
        }

        var targetResource = op.asset;

        if(targetResource == null )
        {
            return default;
        }

        resourcePool.Add(stringBuilder.ToString(), targetResource);
    }

    returnAsset = (T)resourcePool[stringBuilder.ToString()];

    return returnAsset;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 기반 내용으로 이렇게 잡아주었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 완전한 기획이 잡힌 내용이 아니기 때문에, 이후 필요한 내용이 추가될 예정입니다.&lt;/p&gt;</description>
      <category>개인 프로젝트/프로젝트1</category>
      <author>서보훈</author>
      <guid isPermaLink="true">https://gamerscode.tistory.com/149</guid>
      <comments>https://gamerscode.tistory.com/149#entry149comment</comments>
      <pubDate>Mon, 3 Feb 2025 16:14:31 +0900</pubDate>
    </item>
    <item>
      <title>팀 프로젝트 마무리 - Vivox 와 UI 전략 패턴</title>
      <link>https://gamerscode.tistory.com/148</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;음성채팅을 위해 Vivox를 사용하면서, Vivox를 관리하는 클래스와 음성채팅과 관련된 옵션을 만들어주었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;채널 관리 클래스 -&amp;nbsp; VivoxController&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임 시작시, Vivox 채널에 참여하고, 종료시 채널에서 나가는 기능 구현한 클래스 입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기획당시 플레이어의 상태에 따라 음성채팅 연결을 조작할 가능성을 생각하였으며, 해당 내용을 구현했다면 이 클래스에서 조절하게 되었을 것입니다.&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre id=&quot;code_1737024998045&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class VivoxController : MonoSingleton&amp;lt;VivoxController&amp;gt;
{
    private string nowChannelName;

    public async void JoinVoiceChannel(string channelName)
    {
        try
        {
            nowChannelName = channelName;
            await VivoxService.Instance.JoinGroupChannelAsync(channelName, ChatCapability.AudioOnly);
        }
        catch (Exception ex)
        {
            Debug.Log(ex);
        }
    }

    public async void LeaveVoiceChannel()
    {
        await VivoxService.Instance.LeaveChannelAsync(nowChannelName);
        nowChannelName = string.Empty;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;UI 관리 클래스&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;음성채팅 참여시, 관련 UI들을 생성해주는 클래스입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임 종료시 음성채팅에서 나가면서, 관련 UI들을 전부 지우는 역할 또한 합니다.&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1737025142360&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class VoiceChatSetter : MonoSingleton&amp;lt;VoiceChatSetter&amp;gt;
{
    public List&amp;lt;RosterItem&amp;gt; chatList = new List&amp;lt;RosterItem&amp;gt;();
    public RosterItem hudUiPrefab;
    public Transform hudParentPosition;
    public UIVoiceChatOption optionUI;

    protected override void Awake()
    {
        Create();

        if (Instance != this)
        {
            Destroy(gameObject);
        }

        VivoxService.Instance.ParticipantAddedToChannel += OnParticipantAddedToChannel;
        VivoxService.Instance.ParticipantRemovedFromChannel += OnParticipantRemovedFromChannel;
    }

    private void Start()
    {
        //음성채팅 채널에 참가
        VivoxController.Instance.JoinVoiceChannel(NetworkSceneChanger.Instance.VoiceChannelName);
    }

    //Vivox 채널에 참가했을때 HUD UI를 생성, 삭제하는 메서드들
    private void OnParticipantAddedToChannel(VivoxParticipant participant)
    {
        if (participant.PlayerId == AuthenticationService.Instance.PlayerId) return;

        RosterItem newPlayer = Instantiate(hudUiPrefab, hudParentPosition);
        newPlayer.SetupRosterItem(participant);
        chatList.Add(newPlayer);
    }

    private void OnParticipantRemovedFromChannel(VivoxParticipant participant)
    {
        if (participant.PlayerId == VivoxService.Instance.SignedInPlayerId) return;

        for(int i = 0; i &amp;lt; chatList.Count; i++)
        {
            if(chatList[i].participant.PlayerId == participant.PlayerId)
            {
                chatList[i].RemoveSelf();
                chatList.Remove(chatList[i]);
                return;
            }
        }
    }

    public void ClaerChatList()
    {
        VivoxService.Instance.ParticipantAddedToChannel -= OnParticipantAddedToChannel;
        VivoxService.Instance.ParticipantRemovedFromChannel -= OnParticipantRemovedFromChannel;

        for(int i = 0; i &amp;lt; chatList.Count; i++)
        {
            if (chatList[i] == null) continue;

            chatList[i].RemoveSelf();
        }
        chatList.Clear();

        if (optionUI != null)
        {
            Debug.Log(&quot;옵션UI 지우기 시작&quot;);
            optionUI.ClearOptionUIs();
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;HUD UI 관리 클래스&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채널에 참가하면, 게임 HUD UI 에 상대방의 이름을 표기하며, 음성채팅을 사용하고 있는지 확인할 수 있는 UI를 생성하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 HUD UI 가 해당 클래스를 사용하게 됩니다.&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre id=&quot;code_1737025231339&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class RosterItem : MonoBehaviour
{
    public VivoxParticipant participant;

    public TextMeshProUGUI nameText;
    public Image speekImage;

    public Sprite speekSprite;
    public Sprite nonSpeekSprite;
    public Sprite muteSprite;

    private Outline outline;
    private Color originColor;

    private void Awake()
    {
        outline = GetComponent&amp;lt;Outline&amp;gt;();
        originColor = outline.effectColor;
    }

    public void SetupRosterItem(VivoxParticipant newParticipant)
    {
        participant = newParticipant;

        nameText.text = participant.DisplayName;

        participant.ParticipantMuteStateChanged += UpdateChatState;
        participant.ParticipantSpeechDetected += UpdateChatState;
    }

    private void UpdateChatState()
    {
        if(participant.IsMuted)
        {
            //음소거시
            //speekImage.sprite = muteSprite;
        }
        else if (participant.SpeechDetected)
        {
            //입력 감지시
            //speekImage.sprite = speekSprite;
            outline.effectColor = Color.green;
        }
        else
        {
            //그 외(음소거가 아니지만 입력이 없을경우)
            //speekImage.sprite = nonSpeekSprite;
            outline.effectColor = originColor;
        }
    }

    public void RemoveSelf()
    {
        participant.ParticipantMuteStateChanged -= UpdateChatState;
        participant.ParticipantSpeechDetected -= UpdateChatState;
        Destroy(gameObject);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷(1397).png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bGqh2l/btsLQRbaJkR/iH6N0oFE8nnk1sWckHaYMk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bGqh2l/btsLQRbaJkR/iH6N0oFE8nnk1sWckHaYMk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bGqh2l/btsLQRbaJkR/iH6N0oFE8nnk1sWckHaYMk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbGqh2l%2FbtsLQRbaJkR%2FiH6N0oFE8nnk1sWckHaYMk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;1080&quot; data-filename=&quot;스크린샷(1397).png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;음성채팅을 하는중에 좌측 상단의 UI가 녹색빛이 됩니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Vivox 채팅 음량조절 UI&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;음성채팅 소리가 너무 크거나 작을때, 음성채팅 사운드를 조절하기 위한 UI를 관리하는 클래스 입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임 시작시 채널 참가자의 UI를 생성하고, 해당 UI를 조작하여 음량을 조절합니다.&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre id=&quot;code_1737025408386&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class UIVoiceChatOption : MonoBehaviour
{
    public GameObject optionUI;

    public Transform volumOptionUIParent;

    public UIPlayerVoiceSoundController volumeControllerPrefab;

    private List&amp;lt;UIPlayerVoiceSoundController&amp;gt; controllerList = new List&amp;lt;UIPlayerVoiceSoundController&amp;gt;();

    private void Awake()
    {
        VoiceChatSetter.Instance.optionUI = this;
        VivoxService.Instance.ParticipantAddedToChannel += InitPlayerUI;
    }

    //옵션에 음량 조절 UI 생성
    public void InitPlayerUI(VivoxParticipant participant)
    {
        if (participant.PlayerId == AuthenticationService.Instance.PlayerId) return;

        UIPlayerVoiceSoundController controller = Instantiate(volumeControllerPrefab, volumOptionUIParent);
        controller.SetupUI(participant);

        controllerList.Add(controller);
    }

    public void ActiveOptionUI(bool active)
    {
        optionUI.SetActive(active);
        if(active == false)
        {
            OptionUI ui = UIManager.Instance.CreateUI&amp;lt;OptionUI&amp;gt;();
            ui.ClickSetting();
        }
    }

    public void ClearOptionUIs()
    {
        VivoxService.Instance.ParticipantAddedToChannel -= InitPlayerUI;

        for (int i = 0; i &amp;lt; controllerList.Count; i++)
        {
            if (controllerList[i] == null) continue;

            controllerList[i].RemoveSelf();
        }

        controllerList.Clear();
    }
}

public class UIPlayerVoiceSoundController : MonoBehaviour
{
    private VivoxParticipant thisUIParticipant;

    public TextMeshProUGUI playerNameText;
    public Slider volumeControllSlider;

    private string voiceChatVolumeKey = &quot;VoiceChatVolume&quot;;

    public void SetupUI(VivoxParticipant participant)
    {
        thisUIParticipant = participant;

        playerNameText.text = thisUIParticipant.DisplayName;

        int baseVolume = PlayerPrefs.GetInt(voiceChatVolumeKey, 0);
        thisUIParticipant.SetLocalVolume(baseVolume);

        volumeControllSlider.value = thisUIParticipant.LocalVolume;
        //thisUIParticipant.SetLocalVolume(-50);
    }

    public void ChangeVolume()
    {
        int volume = (int)volumeControllSlider.value;
        thisUIParticipant.SetLocalVolume(volume);

        PlayerPrefs.SetInt(voiceChatVolumeKey, volume);
    }

    public void RemoveSelf()
    {
        Destroy(gameObject);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Vivox 는 -50 ~ 50 사이의 음량 조절을 지원합니다. 따라서 음량조절 기능은 기본을 0으로 두고 -50 ~ 50 사이로 정수값으로 조절 가능하게 만들어주었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음으로 Popup 형태의 UI를 활성화/비활성화시 사용된 전략패턴과, 전략패턴을 적용해주는 팩토리 패턴 입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;전략 패턴&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 즉시 활성화/비활성화 되는 None,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이드인, 아웃 방식으로 활성화되는 Fade,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래에서 위로 올라오는 SlideBottom 으로 3가지 패턴을 만들어서 사용하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인스펙터창에서 Enum 값을 선택함으로써, 해당 전략을 사용하도록 팩토리 패턴을 사용하여 UI에 움직임을 주었습니다.&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전략&lt;/p&gt;
&lt;pre id=&quot;code_1737025689503&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public enum UIMovementType
{
    None,
    Fade,
    SlideBottom
}

public interface IPopupStrategy
{
    public void ShowUI();
    public void HideUI();
}

//전략패턴 기반
public class UIStrategyBase
{
    protected GameObject ui;
    protected RectTransform transform;

    public UIStrategyBase(GameObject thisUi)
    {
        ui = thisUi;
        transform = ui.GetComponent&amp;lt;RectTransform&amp;gt;();
    }
}

//전략 : 아래에서 위로 슬라이드 하며 생성
public class UISlideBottomStrategy : UIStrategyBase, IPopupStrategy
{
    private Vector3 hideVector;
    private Vector3 basePosition;

    public UISlideBottomStrategy(GameObject ui) : base(ui)
    {
        hideVector = new Vector3(Screen.width/2, -Screen.height, 0);
        basePosition = new Vector3(Screen.width/2, Screen.height/2, 0);
    }

    public void HideUI()
    {
        transform.position = basePosition;
        CoroutineManager.Instance.StartMyCoroutine(ActiveUI(false));
    }

    public void ShowUI()
    {
        transform.position = hideVector;
        CoroutineManager.Instance.StartMyCoroutine(ActiveUI(true));
    }

    private IEnumerator ActiveUI(bool isActive)
    {
        if(isActive)
        {
            ui.SetActive(true);
            Tween tween = transform.DOAnchorPosY(0, 1f);
            yield return tween.WaitForCompletion();
        }
        else
        {
            Tween tween = transform.DOAnchorPosY(-1080, 1);
            yield return tween.WaitForCompletion();
            ui.SetActive(false);
        }
    }
}

//전략 : 페이드 인, 아웃
public class UIFadeStrategy : UIStrategyBase, IPopupStrategy
{
    private CanvasGroup canvasGroup;

    public UIFadeStrategy(GameObject ui) : base(ui)
    {
        if (!ui.TryGetComponent(out canvasGroup))
        {
            canvasGroup = ui.AddComponent&amp;lt;CanvasGroup&amp;gt;();
        }
    }

    public void HideUI()
    {
        CoroutineManager.Instance.StartMyCoroutine(Fade(true));
    }

    public void ShowUI()
    {
        CoroutineManager.Instance.StartMyCoroutine(Fade(false));
    }

    IEnumerator Fade(bool isFadeIn)
    {
        if(isFadeIn)
        {
            canvasGroup.alpha = 1f;
            Tween tween = canvasGroup.DOFade(0, 1);
            yield return tween.WaitForCompletion();
            ui.SetActive(false);
        }
        else
        {
            canvasGroup.alpha = 0f;
            ui.SetActive(true);
            Tween tween = canvasGroup.DOFade(1, 1);
            yield return tween.WaitForCompletion();
        }
    }
}

//전략 : 아무런 효과가 없음
public class UINoneStrategy : UIStrategyBase, IPopupStrategy
{
    public UINoneStrategy(GameObject ui) : base(ui)
    {
    }

    public void HideUI()
    {
        ui.SetActive(false);
    }

    public void ShowUI()
    {
        ui.SetActive(true);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;팩토리&lt;/p&gt;
&lt;pre id=&quot;code_1737025761067&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public static class UIPopupFactory
{
    // UI에 전략패턴을 만들어주는 팩토리 메서드
    public static IPopupStrategy SetPopupStrategy(GameObject ui, UIMovementType move)
    {
        IPopupStrategy strategy;

        switch (move)
        {
            case UIMovementType.None:
                strategy = new UINoneStrategy(ui);
                break;
            case UIMovementType.Fade:
                strategy = new UIFadeStrategy(ui);
                break;
            case UIMovementType.SlideBottom:
                strategy = new UISlideBottomStrategy(ui);
                break;
            default:
                strategy = new UINoneStrategy(ui);
                break;
        }

        return strategy;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Popup 형식의 UI의 기반이 되는 클래스에 해당 내용을 적용하였습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1737025817286&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class UIPopup : UIBase
{
    //팝업 UI 상속 클래스 (예: 인벤토리창 설정창)
    public UIMovementType movement;

    //UI 활성화, 비활성화시 전략
    private IPopupStrategy strategy;

    //전략패턴 지정
    protected virtual void Awake()
    {
        strategy = UIPopupFactory.SetPopupStrategy(gameObject, movement);
    }

    public override void Show()
    {
        base.Show();
        strategy.ShowUI();
    }

    public override void Hide()
    {
        base.Hide();
        strategy.HideUI();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_스크린샷(1424).png&quot; data-origin-width=&quot;428&quot; data-origin-height=&quot;704&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bbCemQ/btsLP7eLoPd/knXA5kU6AWFK6waODipNVK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bbCemQ/btsLP7eLoPd/knXA5kU6AWFK6waODipNVK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bbCemQ/btsLP7eLoPd/knXA5kU6AWFK6waODipNVK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbbCemQ%2FbtsLP7eLoPd%2FknXA5kU6AWFK6waODipNVK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;428&quot; height=&quot;704&quot; data-filename=&quot;edited_스크린샷(1424).png&quot; data-origin-width=&quot;428&quot; data-origin-height=&quot;704&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로비 UI의 인스펙터 모습 입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Movement에 Slide Bottom 을 선택하여 UI가 아래에서 위로 슬라이드 하듯이 올라오도록 구현하였습니다.&lt;/p&gt;</description>
      <category>내일배움캠프/프로젝트</category>
      <author>서보훈</author>
      <guid isPermaLink="true">https://gamerscode.tistory.com/148</guid>
      <comments>https://gamerscode.tistory.com/148#entry148comment</comments>
      <pubDate>Thu, 16 Jan 2025 20:48:47 +0900</pubDate>
    </item>
    <item>
      <title>팀 프로젝트 마무리 정리 - 멀티플레이 구현</title>
      <link>https://gamerscode.tistory.com/147</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;팀 프로젝트 종료 기한이 다가옴에 따라 최종적으로 작성한 코드들을 정리하는 시간을 가져보고자 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임 기획상 멀티플레이를 구현하게 되었고, 멀트플레이에 사용되는 오브젝트의 클래스들은 NetworkBehavior 를 사용하여 구현하게 되었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;스폰을 위한 클래스 - NetworkSpawnController&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멀티플레이를 준비하면서, 멀티플레이에 동기화 되는 오브젝트들은 서버측에서 스폰을 해주어야 사용이 가능합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스폰 기능은 NetworkObject 에 메서드로써 구현되어있으며, NetworkBehavior 를 가진 클래스는 해당 NetworkObject 컴포넌트를 반드시 가지고 있어야합니다.&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스폰 컨트롤러&lt;/p&gt;
&lt;pre id=&quot;code_1736936284819&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class NetworkSpawnController : MonoSingleton&amp;lt;NetworkSpawnController&amp;gt;
{
    public NetworkObject networkGameManager;
    public NetworkObject professorPrefab;
    public NetworkObject studentPrefab;
    public NetworkObject puzzleRepeaterPrefab;

    private NavMeshSurface navMesh;

    protected override void Awake()
    {
        Create();

        if (Instance != this)
        {
            Destroy(gameObject);
        }

        navMesh = GetComponent&amp;lt;NavMeshSurface&amp;gt;();
        NetworkManager.Singleton.SceneManager.OnSynchronizeComplete += SpawnManager;

        if (NetworkManager.Singleton.IsHost)
        {
            NetworkObject puzzle = Instantiate(puzzleRepeaterPrefab);
            puzzle.Spawn(true);
        }
    }

    public void SpawnManager(ulong clientId)
    {
        if (NetworkManager.Singleton.IsHost &amp;amp;&amp;amp; NetworkManager.ServerClientId != clientId)
        {
            NetworkObject netGameManager = Instantiate(networkGameManager);
            netGameManager.Spawn(true);
        }
    }

    public void SpawnPlayer(ulong professorId, ulong studentId)
    {
        if (!NetworkManager.Singleton.IsHost) return;

        NetworkObject professor = Instantiate(professorPrefab, GameManager.Instance.mapData.professorSpawnPos.position, Quaternion.identity);
        professor.SpawnAsPlayerObject(professorId, true);

        NetworkObject student = Instantiate(studentPrefab, GameManager.Instance.mapData.studentSpawnPos.position, Quaternion.identity);
        student.SpawnAsPlayerObject(studentId, true);
    }

    public void SpawnGhost()
    {
        navMesh.BuildNavMesh();

        //TODO : 반복문으로 바꿔서 모든 지점에 스폰될수 있도록 변경하기
        for(int i = 0; i &amp;lt; GameManager.Instance.mapData.spawnData.Count; i++)
        {
            Ghost ghost = Instantiate(GameManager.Instance.mapData.spawnData[i].ghostPrefab, GameManager.Instance.mapData.spawnData[0].spawnPosition.position, Quaternion.identity);
            ghost.SetPatrolPoints(GameManager.Instance.mapData.patrolPos[i].patrolPositions);
        }

        SpawnItems();

        GameManager.Instance.networkGameManager.SpawnCompleteServerRpc();
    }

    //무작위 아이템 스폰
    private void SpawnItems()
    {
        List&amp;lt;int&amp;gt; spawnIndex = new List&amp;lt;int&amp;gt;(50);
        for(int i = 0; i &amp;lt; GameManager.Instance.mapData.itemSpawnPositionCount; i++)
        {
            int index = Random.Range(0, i + 1);
            spawnIndex.Insert(index, i);
        }

        int maxIndex = DataManager.Instance.GetKeyCount(EDataType.ItemData);

        for (int i = 0; i &amp;lt; GameManager.Instance.mapData.itemSpawnCount; i++)
        {
            int randomIndex = Random.Range(0, maxIndex);
            Transform target = GameManager.Instance.mapData.studentItemSpawnPos[spawnIndex[i]];
            try
            {
                Instantiate(DataManager.Instance.GetDataByInt&amp;lt;ItemData&amp;gt;(randomIndex).ItemPrefab, target.position, Quaternion.identity);
            }
            catch
            {
                Debug.Log(&quot;생성중 문제 발생&quot;);
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메서드는 4종류가 있으며, 각각 서로간 Rpc 통신을 가능하게 하는 NetworkGameManager 의 생성, 두 플레이어의 캐릭터 생성, 학생측 플레이어 씬에 유령 생성, 학생측 플레이어 씬에 아이템을 생성하는 메서드들 입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그와 별개로 Awake 문에서 작동하는 코드가 있는데, 일단 매니저의 스폰을 NetworkSceneManager 의 OnSynchronizeComplete 이벤트에 구독시킵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 이벤트는 두 플레이어의 씬이 동기화되었을때 작동하는 이벤트로, 씬 로드가 완료되면 NetworkGameManager를 스폰시키면서 게임을 시작할 준비를 하게됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 Awake 문에서 퍼즐관련 Rpc를 전담할 NetworkPuzzleRepeater 를 생성합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;※ 이 글을 작성할때 보니 이 내용은 매니저를 스폰할때 같이 처리해주었어야 했던것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NetworkSapwnManager의 경우, 꼭 NetworkObject 가 아니더라도 양 플레이어의 씬이 동기화 된 후 생성해야하는 오브젝트들을 생성하는 역할을 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;멀티플레이 진행용 클래스 - NetworkGameManager&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NetworkGameManager의 경우, 게임의 진행이나 종료등에 필요한 Rpc를 처리하기위해 작성한 클래스입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임을 시작할 때 역할을 분배하는 작업,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임 오버를 처리할 때, 한쪽에서 발생한 게임오버 트리거를 멀티플레이 참여자에게 전달하는 역할을 합니다.&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre id=&quot;code_1736936828445&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class NetworkGameManager : NetworkBehaviour
{
    private NetworkManager networkManager;
    private UnityTransport transport;

    private int isHostProfessor = -1;

    public ulong ProfessorId { get; private set; }
    public ulong StudentId { get; private set; }

    public bool HostReadyFlag { get; private set; }
    public bool ClientReadyFlag { get; private set; }

    //생성 완료 플래그
    private bool isRoleDicided = false;
    private bool isMapCreated = false;

    public override void OnNetworkSpawn()
    {
        GameManager.Instance.networkGameManager = this;

        networkManager = NetworkManager.Singleton;
        transport = networkManager.GetComponent&amp;lt;UnityTransport&amp;gt;();

        DoNextStep();
    }

    private void RoleDicider()
    {
        if (!IsHost) return;

        ulong clientId = 0;

        for(int i = 0; i &amp;lt; networkManager.ConnectedClientsList.Count; i++)
        {
            if (networkManager.ConnectedClientsIds[i] == transport.ServerClientId) continue;

            clientId = networkManager.ConnectedClientsIds[i];
        }

        int random = Random.Range(0, 2);

        if(random == 0)
        {
            isHostProfessor = 0;
            ProfessorId = transport.ServerClientId;
            StudentId = clientId;
        }
        else
        {
            isHostProfessor = 1;
            ProfessorId = clientId;
            StudentId = transport.ServerClientId;
        }

        SetPlayerClientRpc(isHostProfessor);
        isRoleDicided = true;

        DoNextStep();
    }

    [ClientRpc]
    private void SetPlayerClientRpc(int index)
    {
        isHostProfessor = index;

        SetPlayerRole();
    }

    [ServerRpc(RequireOwnership = false)]
    public void SpawnCompleteServerRpc()
    {
        CloseLoadingPanelClientRpc();
    }

    [ClientRpc]
    public void CloseLoadingPanelClientRpc()
    {
        LoadingSceneController.Instance.CloseGameLoadingPanel();

        Cursor.lockState = CursorLockMode.None;
        Cursor.visible = true;

        if(NetworkManager.Singleton.LocalClientId == StudentId)
        {
            UIManager.Instance.CreateUI&amp;lt;PostGraduateGuideUI&amp;gt;();
        }
        else
        {
            UIManager.Instance.CreateUI&amp;lt;ProfessorGuideUI&amp;gt;();
        }
    }

    private void SetPlayerRole()
    {
        if (networkManager.IsHost) return;

        if(isHostProfessor == 0)
        {
            ProfessorId = transport.ServerClientId;
            StudentId = networkManager.LocalClientId;
        }
        else if (isHostProfessor == 1)
        {
            ProfessorId = networkManager.LocalClientId;
            StudentId = transport.ServerClientId;
        }
        else
        {
            Debug.Log(&quot;실패&quot;);
        }

        isRoleDicided = true;
    }

    private void MapSelector()
    {
        if(!IsHost) return;
        //TODO : 차후에 맵을 무작위로 선택하게 만들기
        MapListSO mapList = ResourceManager.Instance.LoadAsset&amp;lt;MapListSO&amp;gt;(&quot;MapDataList&quot;, eAssetType.Data, eCategoryType.SO);

        GameManager.Instance.mapData = mapList.list[0];

        Debug.Log(GameManager.Instance.mapData);

        CreateMapClientRpc(0);

        CreateMap();

        HostReadyFlag = true;

        ReadyComplete();
    }

    [ClientRpc]
    private void CreateMapClientRpc(int index)
    {
        if (IsHost) return;
        MapListSO mapList = ResourceManager.Instance.LoadAsset&amp;lt;MapListSO&amp;gt;(&quot;MapDataList&quot;, eAssetType.Data, eCategoryType.SO);
        GameManager.Instance.mapData = mapList.list[index];

        CreateMap();

        ClientReadyServerRpc();
    }

    private void CreateMap()
    {
        Instantiate(GameManager.Instance.mapData, Vector3.zero, Quaternion.identity);
        isMapCreated = true;
    }

    [ServerRpc(RequireOwnership = false)]
    private void ClientReadyServerRpc()
    {
        if(!IsHost) return;
        ClientReadyFlag = true;

        ReadyComplete();
    }

    private void ReadyComplete()
    {
        if(ClientReadyFlag &amp;amp;&amp;amp; HostReadyFlag)
        {
            ClientReadyFlag = false;
            HostReadyFlag = false;

            DoNextStep();
        }
    }

    private void DoNextStep()
    {
        if (!IsHost) return;

        if (!isRoleDicided)
        {
            RoleDicider();
        }
        else if (!isMapCreated)
        {
            MapSelector();
        }
        else
        {
            NetworkSpawnController.Instance.SpawnPlayer(ProfessorId, StudentId);
            SpawnGhostClientRpc();
        }
    }

    [ClientRpc]
    private void SpawnGhostClientRpc()
    {
        if (NetworkManager.Singleton.LocalClientId == StudentId)
        {
            NetworkSpawnController.Instance.SpawnGhost();
        }
    }

    [ServerRpc(RequireOwnership = false)]
    public void SendGameResultServerRpc(int enumIndex)
    {
        ReciveGameResultClientRpc(enumIndex);
    }

    [ClientRpc]
    private void ReciveGameResultClientRpc(int enumIndex)
    {
        ResultType type = (ResultType)enumIndex;
        GameManager.Instance.Gameover(type);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 클래스는 스폰 될 때, 호스트일 경우 역할을 분배, 맵 선택, 플레이어 와 유령 생성 을 순차적으로 작동합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트에서 작업이 완료되면 ReadyComplete 메서드를 호출하며, 호스트와 클라이언트 모두 준비가 완료되면 다음 작업을 시작하는 방식으로 만들어주었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(상당히 비효율적이라고 생각합니다...)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 주요 역할으로 게임 결과 트리거가 작동되면 게임 결과를 서버로 보내고, 서버는 다시 클라이언트로 게임 결과를 보내어 게임을 종료할 수 있게 하는 역할을 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 씬이 넘어갈 때, 로딩패널을 켜는데, 게임 준비가 모두 완료되면 양측 플레이어의 로딩패널을 끄는 역할을 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;연결 관리 클래스 - NetworkConnectController&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 클래스는 서로간의 멀티플레이 연결을 관리하는 클래스 입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임 종료시 멀티플레이 연결을 끊는 역할과, 연결이 비정상적으로 끊어질경우 로비 씬으로 나갈수 있게 하는 역할을 합니다.&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre id=&quot;code_1736937300180&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class NetworkConnectController : MonoSingleton&amp;lt;NetworkConnectController&amp;gt;
{
    //게스트 플레이어의 경우, 서버와 연결이 끊어졌을때 이벤트를 받아서 연결 종료
    private void Start()
    {
        NetworkManager.Singleton.OnClientDisconnectCallback += ClientDisconnetCallback;
    }

    //서버를 종료시 해야하는 작업들
    public void DisconnetServer()
    {
        NetworkManager.Singleton.Shutdown(true);
        StartCoroutine(WaitingForShutdown());
    }

    //비복스 채널 나가기
    public void ExitVivoxChennel()
    {
        VivoxController.Instance.LeaveVoiceChannel();
        VoiceChatSetter.Instance.ClaerChatList();
    }
    
    //호스트가 서버를 종료하는 메서드
    public void ShutDownHost()
    {
        if (!NetworkManager.Singleton.IsHost) return;
        DisconnetAllClient();

        DisconnetServer();
    }

    //클라이언트 연결 해제
    public void DisconnetAllClient()
    {
        for(int i = 0; i &amp;lt; NetworkManager.Singleton.ConnectedClientsList.Count; i++)
        {
            ulong id = NetworkManager.Singleton.ConnectedClientsIds[i];
            if (id == NetworkManager.Singleton.GetComponent&amp;lt;UnityTransport&amp;gt;().ServerClientId) continue;

            NetworkManager.Singleton.DisconnectClient(id);
        }
    }

    //연결이 끊겼을때 호출되는 메서드
    public void ClientDisconnetCallback(ulong id)
    {
        if (GameManager.Instance.IsGameover == false)
        {
            LobbyManager.Instance.IsPlaying = false;
            LobbyManager.Instance.LeaveLobby();
            GameManager.Instance.DisconnectError();
        }

        LobbyManager.Instance.BackToLobby();
        DisconnetServer();
    }

    private IEnumerator WaitingForShutdown()
    {
        while(NetworkManager.Singleton.ShutdownInProgress)
        {
            yield return null;
        }

        LobbyManager.Instance.IsPlaying = false;
        GameManager.Instance.ActiveExitButton();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;씬 매니저와 다시 플레이를 시작할 때 문제를 경험한 후, 서버 종료시 NetworkManager 의 ShutdownInProgress 값이 true 일때는 서버 연결을 끊는 작업중이며, 반환값이 false 가 되어야 완전히 연결이 끊어진다는것을 알게 되어 호스트가 서버를 종료할경우에 연결이 끊어질때 까지 대기하는 코루틴을 추가하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 해당 클래스에서 Vivox의 연결을 끊는 작업도 관리하고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 외에도 클라이언트의 연결이 끊겼을 때 게임 종료로 인한 연결 끊김인지 비정상적인 연결 끊김인지를 관리하여 게임 결과 UI를 보여주는 역할을 합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;퍼즐 관련 Rpc 관리 클래스 - NetworkPuzzleRepeater&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;퍼즐관련 통신을 담당하는 클래스 입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;퍼즐 시작 트리거가 발생하면 퍼즐의 정답을 생성하는데, 이 정답을 다른 플레이어에게 전달하는 역할과&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;퍼즐의 성공, 실패시 그 결과를 전달해주는 역할을 합니다.&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre id=&quot;code_1736937785052&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class NetworkPuzzleRepeater : NetworkBehaviour
{
    private int curPuzzleIndex;

    public override void OnNetworkSpawn()
    {
        GameManager.Instance.repeater = this;
    }

    [ServerRpc(RequireOwnership = false)]
    public void SynchronizePuzzleServerRpc(int[] hintNumbers, int puzzleIndex)
    {
        SynchronizePuzzleClientRpc(hintNumbers, puzzleIndex);
    }

    [ClientRpc]
    public void SynchronizePuzzleClientRpc(int[] numbers, int puzzleIndex)
    {
        //TODO : 게임 매니저 싱글톤에 Puzzle 올려서 퍼즐에 이 내용 넣어주고 작동시키기
        PuzzleManager.Instance.subPuzzleList[puzzleIndex].SetPuzzleByNetwork(numbers);
        curPuzzleIndex = puzzleIndex;
    }

    [ServerRpc(RequireOwnership = false)]
    public void PuzzleClearServerRpc(int puzzleIndex)
    {
        PuzzleClearClientRpc(puzzleIndex);
    }

    [ClientRpc]
    public void PuzzleClearClientRpc(int puzzleIndex)
    {
        PuzzleManager.Instance.subPuzzleList[puzzleIndex].SetClearByNetwork();
    }

    [ServerRpc(RequireOwnership = false)]
    public void PuzzleStartRequestServerRpc()
    {
        StartPuzzleClientRpc();
    }

    [ClientRpc]
    public void StartPuzzleClientRpc()
    {
        if (GameManager.Instance.networkGameManager.ProfessorId == NetworkManager.Singleton.LocalClientId) return;
        PuzzleManager.Instance.EnterPuzzlePoint();
    }

    [ServerRpc(RequireOwnership = false)]
    public void PuzzleFailServerRpc()
    {
        PuzzleFailClientRpc();
    }

    [ClientRpc]
    public void PuzzleFailClientRpc()
    {
        if (GameManager.Instance.networkGameManager.StudentId == NetworkManager.Singleton.LocalClientId) return;
        PuzzleManager.Instance.subPuzzleList[curPuzzleIndex].FailPuzzleByNetwork();
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;퍼즐들은 기반이 되는 Puzzle 클래스가 있고, 해당 클래스에 네트워크 관련 메서드를 구현한 뒤 override를 하는 방식으로 다른 정보를 관리하는 퍼즐에 접근 할 수 있도록 관리하고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;플레이어 컴포넌트 관리 - PlayerNetwork&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두명의 플레이어는 각자 카메라 위치와, 조작 관련 클래스를 가지고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 컴포넌트들을 캐릭터 소유권에 따라 활성화/비활성화 해주는 클래스입니다.&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre id=&quot;code_1736938130725&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class PlayerNetwork : NetworkBehaviour
{
    //TODO : 플레이어 컴포넌트 전부 다 가져오기
    private CharacterController charController;
    private BasePlayer playerBase;
    private PlayerController controller;
    [SerializeField]
    private GameObject camContainer;
    private InteractionPoint interaction;
    private PlayerEquipment equipment;
    private Inventory inven;

    //스폰할때, 자신 소유가 아니면 전부 비활성화
    public override void OnNetworkSpawn()
    {
        charController = GetComponent&amp;lt;CharacterController&amp;gt;();
        playerBase = GetComponent&amp;lt;BasePlayer&amp;gt;();
        controller = GetComponent&amp;lt;PlayerController&amp;gt;();
        interaction = GetComponent&amp;lt;InteractionPoint&amp;gt;();
        equipment = GetComponent&amp;lt;PlayerEquipment&amp;gt;();
        inven = GetComponent&amp;lt;Inventory&amp;gt;();

        if(OwnerClientId == GameManager.Instance.networkGameManager.StudentId)
        {
            GameManager.Instance.studentPlayerObject = gameObject;
        }

        if(!IsOwner)
        {
            playerBase.audioSource.enabled = false;
            charController.enabled = false;
            playerBase.enabled = false;
            controller.enabled = false;
            interaction.enabled = false;
            equipment.enabled = false;
            inven.enabled = false;

            camContainer.SetActive(false);
        }
        else
        {
            playerBase.audioSource.enabled = true;
            charController.enabled = true;
            playerBase.enabled = true;
            controller.enabled = true;
            interaction.enabled = true;
            equipment.enabled = true;
            inven.enabled = true;

            GameManager.Instance.contolledPlayer = playerBase;

            camContainer.SetActive(true);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역할은 단순히 스폰된 후, 소유권 여부에 따라 컴포넌트를 활성화/비활성화 하는 역할만을 담당하고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최초로 카메라 사용시 2개의 오디오 리스너 문제, 카메라 문제를 해결하기 위해 만들어주었으며, 이후 CharacterController 로 인한 위치문제 등을 해결하기 위해 만들어주었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NetworkBehaviour 의 경우 Awake 대신 OnNetworkSpawn() 메서드를 오버라이드 해서 사용해야한다는것을 알게 된 클래스이기도 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;씬 관리용 클래스 NetworkSceneChanger&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 플레이어의 씬 변경을 관리하기위해 만들어준 클래스입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SceneManager 와 NetworkSceneManager를 모두 사용합니다.&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre id=&quot;code_1736938346579&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class NetworkSceneChanger : MonoSingleton&amp;lt;NetworkSceneChanger&amp;gt;
{
    private Scene currentScene;
    private Scene preScene;
    public string VoiceChannelName { get; set; }

    private void Start()
    {
        currentScene = SceneManager.GetActiveScene();
        SceneManager.LoadScene(&quot;LoadingScene&quot;, LoadSceneMode.Additive);

        //씬 로드후, ActiveScene 내용을 바꿔주기 위해 사용
        SceneManager.sceneLoaded += OnSceneLoad;
    }

    //게임 시작
    public void ChangeScene(string sceneName)
    {
        SoundManager.Instance.StopBGM();

        //이전의 Active씬 언로드
        SceneManager.UnloadSceneAsync(currentScene);

        if (NetworkManager.Singleton.IsHost)
        {
            //호스트가 씬 변경후, 클라이언트와 씬을 동기화
            NetworkManager.Singleton.SceneManager.ActiveSceneSynchronizationEnabled = true;
            NetworkManager.Singleton.SceneManager.SetClientSynchronizationMode(LoadSceneMode.Additive);
            NetworkManager.Singleton.SceneManager.LoadScene(sceneName, LoadSceneMode.Additive);
        }
    }

    private void OnSceneLoad(Scene scene, LoadSceneMode mode)
    {
        //로딩씬이 로드될때는 작동하지 않음
        if (scene.name == &quot;LoadingScene&quot;) return;

        //ActiveScene 변경
        SceneManager.SetActiveScene(scene);
        preScene = currentScene;
        currentScene = scene;

        if(currentScene.name == &quot;LobbyScene&quot;)
        {
            LobbyManager.Instance.CreateLobbyUI();
            LobbyManager.Instance.IsPlaying = false;
        }
    }

    public void ClientChangeScene(string sceneName)
    {
        //연결 해제등의 이유로 서버를 통해서가 아닌 클라이언트가 씬을 변경할때 사용
        SceneManager.UnloadSceneAsync(currentScene);

        SceneManager.LoadScene(sceneName, LoadSceneMode.Additive);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임을 시작할때, SceneManager를 통해 기존 로비씬을 언로드하고, NetworkSceneManager를 통해 플레이씬으로 넘어가며, 게임을 종료할 때는 SceneManager를 통해 플레이 씬을 언로드하고, 로비씬을 로드하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 내용의경우, NetworkSceneManager가 씬을 언로드 할 경우, NetworkSceneManager로 로드된 씬만 언로드 할 수 있음을 알게되면서 이러한 구조를 취하게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 이 클래스에서 로딩씬을 로드하고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 네트워크 연결과 관련된 문제가 만들어지고 있는만큼, 문제가 발생되는것으로 의심되는부분, 게임 로딩중일때의 예외처리가 추가적으로 필요함을 느끼고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가 작업으로 문제 발생시 발생하는 UI를 제작하고, 해당 UI를 통해 어떤 문제가 발생했는지 볼 수 있도록 작업을 진행할 예정입니다.&lt;/p&gt;</description>
      <category>내일배움캠프/프로젝트</category>
      <author>서보훈</author>
      <guid isPermaLink="true">https://gamerscode.tistory.com/147</guid>
      <comments>https://gamerscode.tistory.com/147#entry147comment</comments>
      <pubDate>Wed, 15 Jan 2025 21:00:34 +0900</pubDate>
    </item>
    <item>
      <title>팀 프로젝트 마무리 정리 - 로비</title>
      <link>https://gamerscode.tistory.com/146</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;팀 프로젝트 종료 기한이 다가옴에 따라 최종적으로 작성한 코드들을 정리하는 시간을 가져보고자 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 멀티플레이를 구현하면서 만들어준 로비 관련 시스템 입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 로비 시스템의 구조는 이러합니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;UGS 인증 (Authenticate) - 로비 생성 or 참가 - 로비 내 준비 완료 - 릴레이를 통한 포트 연결&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 NetworkManager를 통해 클라이언트와 호스트가 연결되고, 호스트의 씬 변경 요청을 통해 게임씬으로 넘어가게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;UGS 인증 - NetworkAuthenticate 클래스&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UGS 에 익명으로 로그인한 뒤, Vivox 에도 익명 로그인을 실행하는 클래스입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1736854690750&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class NetworkAuthenticate : MonoSingleton&amp;lt;NetworkAuthenticate&amp;gt;
{
    //로그인 성공시 호출
    public event Action OnAuthenticateSuccessEvent;

    private readonly string loginText = &quot;Connecting&quot;;

    protected async override void Awake()
    {
        //싱글톤 관련 생략...

        await UnityServices.InitializeAsync();
    }

    //인증 메서드 - UGS에 연결
    public async void Authenticate(string playerName)
    {
        LoadingSceneController.Instance.ActiveNetworkLoadingUI(loginText);

        try
        {
            AuthenticationService.Instance.SwitchProfile(playerName);
            //인증 시도
            await AuthenticationService.Instance.SignInAnonymouslyAsync();

            await AuthenticationService.Instance.UpdatePlayerNameAsync(playerName);
        }
        catch (Exception ex)
        {
            Debug.Log(ex);
            Debug.Log(&quot;UGS 연결 실패&quot;);
            LoadingSceneController.Instance.CloseNetworkLoadingUI();
            return;
        }

        try
        {
            await VivoxAuthenticate();
        }
        catch (Exception ex)
        {
            Debug.Log(ex);
            Debug.Log(&quot;Vivox 로그인 실패&quot;);
            LoadingSceneController.Instance.CloseNetworkLoadingUI();
            return;
        }

        OnAuthenticateSuccessEvent?.Invoke();
    }

    public async Task VivoxAuthenticate()
    {
        await VivoxService.Instance.InitializeAsync();

        LoginOptions options = new LoginOptions();

        options.DisplayName = AuthenticationService.Instance.PlayerName;

        await VivoxService.Instance.LoginAsync(options);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인증을 하기 위해 UnityService 를 초기화하는 과정을 이 클래스 Awake 문에서 진행하며&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;확인 버튼에 Authenticate 메서드를 연동하여 메서드를 호축하게 됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_스크린샷(1407).png&quot; data-origin-width=&quot;1056&quot; data-origin-height=&quot;572&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cfwNSO/btsLN5UYxaH/CyvPID0BW8x5SRzn8Qx381/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cfwNSO/btsLN5UYxaH/CyvPID0BW8x5SRzn8Qx381/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cfwNSO/btsLN5UYxaH/CyvPID0BW8x5SRzn8Qx381/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcfwNSO%2FbtsLN5UYxaH%2FCyvPID0BW8x5SRzn8Qx381%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1056&quot; height=&quot;572&quot; data-filename=&quot;edited_스크린샷(1407).png&quot; data-origin-width=&quot;1056&quot; data-origin-height=&quot;572&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1736854918464&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public void LoginButton()
{
    PlayButtonSFX();
    NetworkAuthenticate.Instance.Authenticate(nameInput.text);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AuthenticateService 를 통해 익명 인증이 완료되면 바로 다음 작업으로 Vivox 에 로그인을 시도하며,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Vivox 익명 로그인을 성공하면 로비를 찾는 UI 로 넘어가게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;로비 관리 - LobbyManager&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로비와 관련된 모든 작업을 하는 매니저 클래스 입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UGS 로비 특성상 25초마다 하트비트 신호를 보내도록 하였으며, 1초 마다 로비 정보를 업데이트 하도록 만들어주었습니다.&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로비 전체 코드 입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1736855206790&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class LobbyManager : MonoSingleton&amp;lt;LobbyManager&amp;gt;
{
    //UI관리용 이벤트
    //로비에 참여하면 호출
    public event Action&amp;lt;Lobby&amp;gt; OnJoinLobbyEvent;
    public event Action OnJoinSuccessEvent;
    //로비에서 나가면 호출
    public event Action OnLeaveLobbyEvent;
    //강퇴 당할경우 호출
    public event Action OnKickedFromLobbyEvent;
    //플레이어 준비 완료 여부를 확인할 때 호출
    public event Action&amp;lt;bool&amp;gt; OnPlayerReadyEvent;
    //게임 시작시 호출 - TODO : Relay 연동하기
    public event Action OnGameStartEvent;

    //현재 플레이어가 참가한 로비 정보
    private Lobby nowLobby;

    //인증용 플레이어의 이름
    private string playerName;

    //로비 관리용 타이머
    private float lobbyMaintainTimer = 0;
    private float lobbyInfoUpdateTimer = 0;

    //로비의 최대 플레이어 수
    private readonly int maxPlayer = 2;
    //로비 재활성화 시간
    private readonly float lobbyMaintainTime = 25f;
    //로비 정보 확인 시간
    private readonly float lobbyInfoUpdateTime = 1f;

    public bool IsPlaying { get; set; } = false;

    private string vivoxChannelName;

    private NetworkStringKey networkKey = new NetworkStringKey();

    private void Start()
    {
        Application.targetFrameRate = 120;

        NetworkAuthenticate.Instance.OnAuthenticateSuccessEvent += SetPlayerName;

        if(AuthenticationService.Instance.IsSignedIn)
        {
            playerName = AuthenticationService.Instance.PlayerName;
        }

        CreateLobbyUI();
    }

    protected override void Update()
    {
        base.Update();

        MaintainLobbyAlive();

        if (nowLobby != null &amp;amp;&amp;amp; IsPlaying == false)
        {
            RefreshLobbyInfo();
        }
    }

    public void SetPlayerName()
    {
        playerName = AuthenticationService.Instance.PlayerName;
    }

    //로비 생성, 일단 무조건 비밀방으로
    public async void CreateLobby(string lobbyName)
    {
        CreateLobbyOptions createOption = new CreateLobbyOptions
        {
            Player = GetPlayer(true),
            IsPrivate = true,

            //참여자에게 게임 시작을 알리는 키 생성
            Data = new Dictionary&amp;lt;string, DataObject&amp;gt;
            {
                {networkKey.GameStartKey, new DataObject(DataObject.VisibilityOptions.Member, networkKey.ReadyKey) }
            }
        };

        Lobby lobby = await LobbyService.Instance.CreateLobbyAsync(lobbyName, maxPlayer, createOption);

        nowLobby = lobby;

        //로비 참가시 UI작동용
        OnJoinLobbyEvent?.Invoke(nowLobby);
        OnPlayerReadyEvent?.Invoke(false);
    }

    //플레이어 정보 받아오기
    private Player GetPlayer(bool isCreate)
    {
        string readyOption = isCreate.ToString();

        return new Player(
            id: AuthenticationService.Instance.PlayerId,
            data: new Dictionary&amp;lt;string, PlayerDataObject&amp;gt;()
            {
                {
                    networkKey.PlayerNameKey, new PlayerDataObject(PlayerDataObject.VisibilityOptions.Member, playerName)
                },
                {
                    networkKey.ReadyKey, new PlayerDataObject(PlayerDataObject.VisibilityOptions.Member, readyOption)
                }

            });
    }

    //30초가 되기 전 로비를 업데이트
    public async void MaintainLobbyAlive()
    {
        //호스트가 아니면 작동하지 않음
        if (!IsLobbyhost()) return;

        lobbyMaintainTimer += Time.deltaTime;
        if (lobbyMaintainTimer &amp;lt; lobbyMaintainTime) return;

        lobbyMaintainTimer = 0;

        await LobbyService.Instance.SendHeartbeatPingAsync(nowLobby.Id);
    }

    //로비 참가(코드로)
    public async void JoinLobbyByCode(string code)
    {
        var joinOption = new JoinLobbyByCodeOptions { Player = GetPlayer(false) };

        try
        {
            nowLobby = await LobbyService.Instance.JoinLobbyByCodeAsync(code, joinOption);
        }
        catch (LobbyServiceException ex)
        {
            if(ex.Reason == LobbyExceptionReason.InvalidJoinCode)
            {
                Debug.Log(&quot;코드에 일치하는 방이 없음&quot;);
            }
            Debug.Log(ex.Reason);
            return;
        }

        OnJoinSuccessEvent?.Invoke();
        OnJoinLobbyEvent?.Invoke(nowLobby);
    }

    //로비 정보 업데이트
    public async void RefreshLobbyInfo()
    {
        if(nowLobby == null) return;

        lobbyInfoUpdateTimer += Time.deltaTime;
        if(lobbyInfoUpdateTimer &amp;lt; lobbyInfoUpdateTime) return;

        lobbyInfoUpdateTimer = 0;

        bool isReady = true;

        try
        {
            Lobby lobby = await LobbyService.Instance.GetLobbyAsync(nowLobby.Id);
            nowLobby = lobby;

            if (nowLobby.Players.Count &amp;gt; 1)
            {
                for (int i = 0; i &amp;lt; nowLobby.Players.Count; i++)
                {
                    if (!bool.Parse(nowLobby.Players[i].Data[networkKey.ReadyKey].Value))
                    {
                        isReady = false;
                        break;
                    }
                }
            }
            else
            {
                isReady = false;
            }

            OnPlayerReadyEvent?.Invoke(isReady);

            //로비의 GameStartKey 가 자신과 다르면 키 내용으로 릴레이에 참가
            if (nowLobby.Data[networkKey.GameStartKey].Value != networkKey.ReadyKey)
            {
                if (!IsLobbyhost())
                {
                    //호스트가 아니면, 키 내용으로 릴레이 참가
                    RelayManager.Instance.JoinRelay(nowLobby.Data[networkKey.GameStartKey].Value);

                    vivoxChannelName = nowLobby.Name;
                    NetworkSceneChanger.Instance.VoiceChannelName = vivoxChannelName;

                    SetReady(false);

                    //게임 시작 이벤트 호출
                    IsPlaying = true;
                    NetworkSceneChanger.Instance.ChangeScene(&quot;PlayScene&quot;);
                    OnGameStartEvent?.Invoke();
                    return;
                }
            }
        }
        catch (LobbyServiceException ex)
        {
            //로비정보 업데이트중, 403 오류 발생시 강퇴 or 로비가 사라졌음 처리
            if(ex.Reason == LobbyExceptionReason.Forbidden)
            {
                nowLobby = null;
                OnKickedFromLobbyEvent?.Invoke();
                return;
            }
        }

        if(!IsPlayerInLobby())
        {
            nowLobby = null;
            return;
        }

        //정보 갱신
        OnJoinLobbyEvent?.Invoke(nowLobby);
    }

    //호스트 여부를 알려주는 메서드
    public bool IsLobbyhost() =&amp;gt; nowLobby != null &amp;amp;&amp;amp; nowLobby.HostId == AuthenticationService.Instance.PlayerId;
    
    //
    private bool IsPlayerInLobby()
    {
        if(nowLobby == null || nowLobby.Players == null) return false;

        foreach(var player in nowLobby.Players)
        {
            if (player.Id != AuthenticationService.Instance.PlayerId) continue;
            return true;
        }

        return false;
    }

    //로비 떠나기
    public async void LeaveLobby()
    {
        if (nowLobby == null) return;

        //호스트일경우, 로비 자체가 사라지도록
        if(IsLobbyhost())
        {
            for (int i = 1; i &amp;lt; nowLobby.Players.Count; i++)
            {
                if (nowLobby.Players[i].Id == AuthenticationService.Instance.PlayerId) continue;

                await LobbyService.Instance.RemovePlayerAsync(nowLobby.Id, nowLobby.Players[i].Id);
            }
        }

        try
        {
            await LobbyService.Instance.RemovePlayerAsync(nowLobby.Id, AuthenticationService.Instance.PlayerId);
            nowLobby = null;
            OnLeaveLobbyEvent?.Invoke();
        }
        catch(Exception ex)
        {
            Debug.Log(ex);
            nowLobby = null;
            OnLeaveLobbyEvent?.Invoke();
        }
    }

    public async void KickPlayer(string id)
    {
        if (!IsLobbyhost()) return;
        if (id == AuthenticationService.Instance.PlayerId) return;

        try
        {
            await LobbyService.Instance.RemovePlayerAsync(nowLobby.Id, id);
        }
        catch (LobbyServiceException ex)
        {
            Debug.Log(ex);
        }
    }

    public async void GameStart()
    {
        if(!IsLobbyhost() || IsPlaying == true) return;

        try
        {
            //릴레이 코드 가져오기
            string relayCode = await RelayManager.Instance.CreateRelay();

            Lobby lobby = await Lobbies.Instance.UpdateLobbyAsync(nowLobby.Id, new UpdateLobbyOptions
            {
                //로비의 GameStartKey 를 릴레이 코드로 변경
                Data = new Dictionary&amp;lt;string, DataObject&amp;gt;
                {
                    {networkKey.GameStartKey, new DataObject(DataObject.VisibilityOptions.Member, relayCode) }
                }
            });

            vivoxChannelName = nowLobby.Name;
            nowLobby = lobby;

            NetworkSceneChanger.Instance.VoiceChannelName = vivoxChannelName;

            IsPlaying = true;
            OnGameStartEvent?.Invoke();
            ChangeGameScene();
        }
        catch (LobbyServiceException e)
        {
            Debug.Log(e);
        }
    }

    //씬 변경
    public void ChangeGameScene()
    {
        //임시로 테스트씬 하나 추가
        NetworkSceneChanger.Instance.ChangeScene(&quot;PlayScene&quot;);
    }

    public Lobby GetNowLobby()
    {
        return nowLobby;
    }

    //호스트가 아니면 준비버튼 사용 가능
    public async void SetReady( bool isReady)
    {
        if (nowLobby == null) return;

        string readyOption = isReady.ToString();
        string playerId = AuthenticationService.Instance.PlayerId;

        UpdatePlayerOptions playerOptions = new UpdatePlayerOptions();
        playerOptions.Data = new Dictionary&amp;lt;string, PlayerDataObject&amp;gt;()
                {
                    {networkKey.PlayerNameKey, new PlayerDataObject(PlayerDataObject.VisibilityOptions.Member, playerName)},
                    {networkKey.ReadyKey, new PlayerDataObject(PlayerDataObject.VisibilityOptions.Member, readyOption) }
                };

        try
        {
            var lobby = await LobbyService.Instance.UpdatePlayerAsync(nowLobby.Id, playerId, playerOptions);
            nowLobby = lobby;

            RefreshLobbyInfo();
        }
        catch (LobbyServiceException e)
        {
            Debug.Log(e);
            LeaveLobby();
        }
    }

    //게임 종료후, 로비 재참여
    public async void BackToLobby()
    {
        if (!IsLobbyhost()) return;
        try
        {
            Lobby lobby = await Lobbies.Instance.UpdateLobbyAsync(nowLobby.Id, new UpdateLobbyOptions
            {
                //로비의 GameStartKey 를 릴레이 코드로 변경
                Data = new Dictionary&amp;lt;string, DataObject&amp;gt;
                {
                    {networkKey.GameStartKey, new DataObject(DataObject.VisibilityOptions.Member, networkKey.ReadyKey) }
                }
            });

            nowLobby = lobby;
            RefreshLobbyInfo();
        }
        catch (Exception e)
        {
            Debug.Log(e);
            LeaveLobby();
        }
    }

    public void CreateLobbyUI()
    {
        PlayLobbyBGM();

        LobbyUI ui = UIManager.Instance.CreateUI&amp;lt;LobbyUI&amp;gt;();
        ui.Show();
    }

    public void PlayLobbyBGM()
    {
        SoundManager.Instance.ChangeBGM(eBGMType.LobbyBGM);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_스크린샷(1408).png&quot; data-origin-width=&quot;1060&quot; data-origin-height=&quot;600&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bpCKv6/btsLOvMxQrD/Q0HnnYFdGo4ZKBG6HWDkCk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bpCKv6/btsLOvMxQrD/Q0HnnYFdGo4ZKBG6HWDkCk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bpCKv6/btsLOvMxQrD/Q0HnnYFdGo4ZKBG6HWDkCk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbpCKv6%2FbtsLOvMxQrD%2FQ0HnnYFdGo4ZKBG6HWDkCk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1060&quot; height=&quot;600&quot; data-filename=&quot;edited_스크린샷(1408).png&quot; data-origin-width=&quot;1060&quot; data-origin-height=&quot;600&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재는 로비 참가를 코드로만 진행중에 있으며, 커스텀 키 를 사용하여 플레이어 준비 상태를 관리, 게임 시작 상태 체크 등의 역할을 추가해주었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 게임이 정상적으로 종료시 로비로 다시 돌아오도록 설계를 변경하면서, 로비로 돌아올경우 UI를 다시 만들어주는 코드와 게임 시작 관련 커스텀 키 값을 변경해주는 코드를 추가해주었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재는 코드를 통해 방에 참여하는 방식만을 사용중인데, 개인적으로는 공개방에 참가할 수 있도록 설계하는것 또한 고려했었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단, 제작이 늦춰지면서 멀티플레이쪽에 더이상 시간을 쓸 수 없게되어 코드 참가기능만 존재하는것이 아쉬울 따름입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로비를 생성하거나 참가하게 되면, 해당 로비에 연동되는 UI들이 존재해야합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로비 관련 UI는 두종류가 있는데, 하나는 로비 전체를 관리하는 UI,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 하나는 로비에 참여한 플레이어의 정보를 표기하는 UI 입니다.&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로비 전체 관리 클래스&lt;/p&gt;
&lt;pre id=&quot;code_1736855556912&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class LobbyUI : UIPopup
{
    public TMP_InputField nameInput;
    public TMP_InputField roomCodeInput;

    //로그인, 방검색, 로비 UI본체
    public GameObject loginUI;
    public GameObject searchLobbyUI;
    public GameObject lobbyUI;

    //로비 UI에서 사용되는 필드
    public LobbyUIPlayerInfo infoPrefab;
    public Transform infoPerant;
    public TextMeshProUGUI roomCodeText;
    public Button StartButton;

    //로비 UI의 책 넘기는 효과를 발생시키기 위한 이벤트
    public event Action OnFlipRightEvent;
    public event Action OnFlipLeftEvent;
    public event Action&amp;lt;int&amp;gt; OnFlipToTargetEvent;

    private void Start()
    {
        NetworkAuthenticate.Instance.OnAuthenticateSuccessEvent += SuccessLogin;
        LobbyManager.Instance.OnJoinLobbyEvent += JoinLobby;
        LobbyManager.Instance.OnJoinSuccessEvent += JoinSuccess;
        LobbyManager.Instance.OnLeaveLobbyEvent += LeaveLobby;
        LobbyManager.Instance.OnKickedFromLobbyEvent += LeaveLobby;
        LobbyManager.Instance.OnPlayerReadyEvent += GameStartButtonControll;

        if (AuthenticationService.Instance.IsSignedIn == false)
        {
            loginUI.SetActive(true);
            searchLobbyUI.SetActive(false);
            lobbyUI.SetActive(false);
        }
        else
        {
            CloseAllUIs();

            if (LobbyManager.Instance.GetNowLobby() == null)
            {
                OnFlipToTargetEvent?.Invoke(2);
            }
            else
            {
                OnFlipToTargetEvent?.Invoke(4);
            }
        }
    }

    private void Update()
    {
        if (AuthenticationService.Instance.IsSignedIn == false) return;
        if(VivoxService.Instance.IsLoggedIn == false) return;

        //로비 오류 문제 수정용
        if(LobbyManager.Instance.GetNowLobby() == null)
        {
            lobbyUI.SetActive(false);
            searchLobbyUI.SetActive(true);
            OnFlipToTargetEvent?.Invoke(2);
        }
    }

    public void CloseAllUIs()
    {
        loginUI.SetActive(false);
        searchLobbyUI.SetActive(false);
        lobbyUI.SetActive(false);
    }

    public void SuccessLogin()
    {
        loginUI.SetActive(false);
    }

    private void JoinLobby(Lobby lobby)
    {
        bool isHost = false;

        if (lobbyUI.activeInHierarchy == false)
        {
            searchLobbyUI.SetActive(false);
        }

        roomCodeText.text = lobby.LobbyCode;

        if(AuthenticationService.Instance.PlayerId == lobby.HostId)
        {
            StartButton.gameObject.SetActive(true);
            isHost = true;
        }
        else
        {
            StartButton.gameObject.SetActive(false);
        }

        if (infoPerant.childCount != 0)
        {
            for (int i = 0; i &amp;lt; infoPerant.childCount; i++)
            {
                Destroy(infoPerant.GetChild(i).gameObject);
            }
        }

        for(int i = 0; i &amp;lt; lobby.Players.Count; i++)
        {
            LobbyUIPlayerInfo info = Instantiate(infoPrefab, infoPerant);
            info.SetPlayerInfo(lobby.Players[i], lobby.Players[i].Id == lobby.HostId);

            if(!isHost)
            {
                info.ActiveKickButton(false);

                info.ActiveReadyButton(lobby.Players[i].Id == AuthenticationService.Instance.PlayerId);
            }
            else
            {
                info.ActiveReadyButton(false);
            }
        }
    }

    private void LeaveLobby()
    {
        lobbyUI.SetActive(false);
        OnFlipLeftEvent?.Invoke();
    }

    public void GameStartButtonControll(bool isActive)
    {
        if (LobbyManager.Instance.IsLobbyhost() == false) return;
        StartButton.interactable = isActive;
    }

    public void CreateRoomButton()
    {
        PlayButtonSFX();
        LobbyManager.Instance.CreateLobby(roomCodeInput.text);
        searchLobbyUI.SetActive(false);
        OnFlipRightEvent?.Invoke();
    }

    public void JoinRoomButton()
    {
        PlayButtonSFX();
        LobbyManager.Instance.JoinLobbyByCode(roomCodeInput.text);
    }

    public void JoinSuccess()
    {
        searchLobbyUI.SetActive(false);
        OnFlipRightEvent?.Invoke();
    }

    public void LoginButton()
    {
        PlayButtonSFX();
        NetworkAuthenticate.Instance.Authenticate(nameInput.text);
    }

    public void GameStartButton()
    {
        PlayButtonSFX();
        LobbyManager.Instance.GameStart();
    }

    public void LeaveLobbyButton()
    {
        PlayButtonSFX();
        LobbyManager.Instance.LeaveLobby();
    }

    private void OnDestroy()
    {
        NetworkAuthenticate.Instance.OnAuthenticateSuccessEvent -= SuccessLogin;
        LobbyManager.Instance.OnJoinLobbyEvent -= JoinLobby;
        LobbyManager.Instance.OnJoinSuccessEvent -= JoinSuccess;
        LobbyManager.Instance.OnLeaveLobbyEvent -= LeaveLobby;
        LobbyManager.Instance.OnKickedFromLobbyEvent -= LeaveLobby;
        LobbyManager.Instance.OnPlayerReadyEvent -= GameStartButtonControll;
    }

    public void QuitGame()
    {
        PlayButtonSFX();
        Application.Quit();
    }

    public void ControllInfoButton()
    {
        PlayButtonSFX();
        UIControllInfo ui = UIManager.Instance.CreateUI&amp;lt;UIControllInfo&amp;gt;();
        ui.Show();
    }

    public void PlayButtonSFX()
    {
        SoundManager.Instance.PlayOneShotSFX(eSFXType.LobbyButtonSFX);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플레이어 정보 관리 UI&lt;/p&gt;
&lt;pre id=&quot;code_1736855581673&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class LobbyUIPlayerInfo : MonoBehaviour
{
    public TextMeshProUGUI hostText;
    public TextMeshProUGUI playerNameText;
    public GameObject kickButton;
    public GameObject readyButton;

    public GameObject readyImage;

    public string playerId;
    private bool isReady = false;
    
    public void SetPlayerInfo(Player player, bool isHostId)
    {
        if (isHostId)
        {
            hostText.text = &quot;Host&quot;;
            kickButton.SetActive(false);
        }
        else
        {
            hostText.text = &quot;Guest&quot;;
            kickButton.SetActive(true);
        }

        playerNameText.text = player.Data[&quot;PlayerName&quot;].Value;
        playerId = player.Id;

        if (bool.Parse(player.Data[&quot;IsPlayerReady&quot;].Value) &amp;amp;&amp;amp; isHostId == false)
        {
            isReady = true;
            readyImage.SetActive(true);
        }
    }

    //킥 버튼 관리 (호스트에게만 보이도록)
    public void ActiveKickButton(bool isActive)
    {
        kickButton.SetActive(isActive);
    }

    public void ActiveReadyButton(bool isActive)
    {
        readyButton.SetActive(isActive);
    }

    public void KickButtonClick()
    {
        LobbyManager.Instance.KickPlayer(playerId);
    }

    public void ReadyButtonClick()
    {
        if(isReady)
        {
            isReady = false;
            readyImage.SetActive(false);
            LobbyManager.Instance.SetReady(isReady);
        }
        else
        {
            isReady = true;
            readyImage.SetActive(true);
            LobbyManager.Instance.SetReady(isReady);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷1.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bAJMhI/btsLMKxR0Iu/1cB1zIKnlrYOhHYqToQ3FK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bAJMhI/btsLMKxR0Iu/1cB1zIKnlrYOhHYqToQ3FK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bAJMhI/btsLMKxR0Iu/1cB1zIKnlrYOhHYqToQ3FK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbAJMhI%2FbtsLMKxR0Iu%2F1cB1zIKnlrYOhHYqToQ3FK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;1080&quot; data-filename=&quot;스크린샷1.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로비의 경우, LobbyManager에서 발급된 이벤트를 통해 내용이 관리되며, 이를통해 생성될 때 이벤트에 구독하고 파괴될때 해제 하면서 정보를 관리할 수 있도록 만들어주었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 LobbyManager에서 1초마다 로비 정보를 갱신할 때, 플레이어 정보 UI도 갱신하는 방식으로 로비 참여자 정보를 관리하고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;호스트 - 클라이언트 간 연결 : RelayManager&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Relay는 포트포워딩 등의 작업을 대신해준다고 생각할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RelayManager는 호스트의 참여 키를 만들어주는 메서드와&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트의 참여 키를 통해 서버에 참가하는 메서드 2개의 기능이 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1736855748384&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class RelayManager : MonoSingleton&amp;lt;RelayManager&amp;gt;
{
    //씬을 넘어가지 않도록 Awake 재정의
    protected override void Awake()
    {
        //...싱글톤 관련 생략
    }

    //릴레이서버를 만드는 메서드
    public async Task&amp;lt;string&amp;gt; CreateRelay()
    {
        try
        {
            var playerCount = LobbyManager.Instance.GetNowLobby().MaxPlayers - 1;
            Allocation allocation = await RelayService.Instance.CreateAllocationAsync(playerCount);

            string joinCode = await RelayService.Instance.GetJoinCodeAsync(allocation.AllocationId);

            //TODO : NetworkManager 연동
            NetworkManager.Singleton.GetComponent&amp;lt;UnityTransport&amp;gt;().SetHostRelayData
                (
                allocation.RelayServer.IpV4,
                (ushort)allocation.RelayServer.Port,
                allocation.AllocationIdBytes,
                allocation.Key,
                allocation.ConnectionData
                );

            NetworkManager.Singleton.StartHost();

            return joinCode;
        }
        catch(RelayServiceException e)
        {
            Debug.Log(e);
            return null;
        }
    }

    //호스트에게 참여하는 메서드
    public async void JoinRelay(string joinCode)
    {
        try
        {
            JoinAllocation joinAllocation = await RelayService.Instance.JoinAllocationAsync(joinCode);

            //TODO : NetworkManager 연동
            NetworkManager.Singleton.GetComponent&amp;lt;UnityTransport&amp;gt;().SetClientRelayData
                (
                joinAllocation.RelayServer.IpV4,
                (ushort)joinAllocation.RelayServer.Port,
                joinAllocation.AllocationIdBytes,
                joinAllocation.Key,
                joinAllocation.ConnectionData,
                joinAllocation.HostConnectionData
                );

            NetworkManager.Singleton.StartClient();
        }
        catch (RelayServiceException e)
        {
            Debug.Log(e);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지가 로비에서 사용되는 클래스들 입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 차근차근 실제 멀티플레이중에 사용되는 클래스, Vivox 관련 클래스, 씬 변경에 관련된 클래스를 정리해 줄 예정입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인적인 평가로는 작성할 때는 몰랐지만 예외처리 부분에서 많은 문제가 보이기 시작했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 작업을 기반으로, 네트워크 관련 예외처리를 진행할 경우, 문제 상황을 보여주는 UI 와의 연동과, 문제 발생 후 작동 코드를 추가로 작성하는 내용에 대해 더 생각해볼 수 있는 기회가 된 것 같습니다.&lt;/p&gt;</description>
      <category>내일배움캠프/프로젝트</category>
      <author>서보훈</author>
      <guid isPermaLink="true">https://gamerscode.tistory.com/146</guid>
      <comments>https://gamerscode.tistory.com/146#entry146comment</comments>
      <pubDate>Tue, 14 Jan 2025 21:00:12 +0900</pubDate>
    </item>
    <item>
      <title>팀 프로젝트 - 퍼즐 힌트 시작 수정</title>
      <link>https://gamerscode.tistory.com/145</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;퍼즐의 힌트를 시작할 때, 힌트를 보고 정보를 말해주어야하는 쪽은 교수측 플레이어지만, 그 트리거를 작동시키는것은 학생측인것이 불합리하다고 생각하여 퍼즐의 힌트를 받는 방법을 바꾸어주었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_스크린샷(1401).png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1037&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GZVc1/btsLKLX37pr/qHeY6QDesKfKhftlqwPRDK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GZVc1/btsLKLX37pr/qHeY6QDesKfKhftlqwPRDK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GZVc1/btsLKLX37pr/qHeY6QDesKfKhftlqwPRDK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGZVc1%2FbtsLKLX37pr%2FqHeY6QDesKfKhftlqwPRDK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;1037&quot; data-filename=&quot;edited_스크린샷(1401).png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1037&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;석탑에 교수측 플레이어가 상호작용을 할 경우, 퍼즐이 활성화되며 힌트인 촛불이 작동하도록 변경해주었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;석탑의 경우, 작동시 Rpc를 보내는 역할만 하게 됩니다.&lt;/p&gt;
&lt;pre id=&quot;code_1736764595104&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class PuzzleStartObject : MonoBehaviour, IInteractable
{
    public string GetInteractText()
    {
        return &quot;만져보기&quot;;
    }

    public void Interact()
    {
        GameManager.Instance.repeater.PuzzleStartRequestServerRpc();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;퍼즐 정보를 클라이언트에게 보내는 클래스입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1736764640971&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class NetworkPuzzleRepeater : NetworkBehaviour
{
    private int curPuzzleIndex;

    //...생략

    [ServerRpc(RequireOwnership = false)]
    public void PuzzleClearServerRpc(int puzzleIndex)
    {
        PuzzleClearClientRpc(puzzleIndex);
    }

    [ClientRpc]
    public void PuzzleClearClientRpc(int puzzleIndex)
    {
        PuzzleManager.Instance.subPuzzleList[puzzleIndex].SetClearByNetwork();
    }

    [ServerRpc(RequireOwnership = false)]
    public void PuzzleStartRequestServerRpc()
    {
        StartPuzzleClientRpc();
    }

    [ClientRpc]
    public void StartPuzzleClientRpc()
    {
        if (GameManager.Instance.networkGameManager.ProfessorId == NetworkManager.Singleton.LocalClientId) return;
        PuzzleManager.Instance.EnterPuzzlePoint();
    }

    [ServerRpc(RequireOwnership = false)]
    public void PuzzleFailServerRpc()
    {
        PuzzleFailClientRpc();
    }

    [ClientRpc]
    public void PuzzleFailClientRpc()
    {
        if (GameManager.Instance.networkGameManager.StudentId == NetworkManager.Singleton.LocalClientId) return;
        PuzzleManager.Instance.subPuzzleList[curPuzzleIndex].FailPuzzleByNetwork();
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;퍼즐 작동의 경우, 팀원이 만든 내용이기에 해당 내용을 수정하지 않는 쪽으로 구현해주었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트리거가 작동되면 스크립트를 활성화 하면서 퍼즐이 진행되는 방식이기에, 시작 트리거는 교수측에서 작동하지만 퍼즐의 정답 생성은 학생측에서 진행하는 형태로 구현해주었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후, 퍼즐이 실패할경우 다시 힌트를 받기위해 퍼즐을 초기화 해줄 필요가 있었으며, 이를 위해 실패시 작동할 Rpc도 추가해주었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>내일배움캠프/프로젝트</category>
      <author>서보훈</author>
      <guid isPermaLink="true">https://gamerscode.tistory.com/145</guid>
      <comments>https://gamerscode.tistory.com/145#entry145comment</comments>
      <pubDate>Mon, 13 Jan 2025 21:45:17 +0900</pubDate>
    </item>
    <item>
      <title>팀 프로젝트 - 아이템 무작위 배치</title>
      <link>https://gamerscode.tistory.com/144</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;맵을 구성함과 동시에, 아이템을 무작위로 배치하는 기능을 만들어주었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;노란색 아이콘이 아이템이 배치될 위치를 지정해둔 오브젝트들 입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_스크린샷(1382).png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1038&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bMLfdF/btsLGOgfpGN/TgdUcUAZtmtXIP8XEFwfN1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bMLfdF/btsLGOgfpGN/TgdUcUAZtmtXIP8XEFwfN1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bMLfdF/btsLGOgfpGN/TgdUcUAZtmtXIP8XEFwfN1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbMLfdF%2FbtsLGOgfpGN%2FTgdUcUAZtmtXIP8XEFwfN1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;1038&quot; data-filename=&quot;edited_스크린샷(1382).png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1038&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맵 데이터에 아이템이 생성될 위치를 가진 부모 오브젝트를 등록시켜둔 상태이며, 각 건물 프리팹 내에 아이템이 스폰될 수 있는 위치를 지정하는 빈 오브젝트를 만들어둔 상태입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dJR2So/btsLIb84qRw/9USvcJAgeU2eB3wBgzygWK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dJR2So/btsLIb84qRw/9USvcJAgeU2eB3wBgzygWK/img.png&quot; data-origin-width=&quot;361&quot; data-origin-height=&quot;350&quot; data-is-animation=&quot;false&quot; data-filename=&quot;edited_스크린샷(1384).png&quot; style=&quot;width: 53.9365%; margin-right: 10px;&quot; data-widthpercent=&quot;54.57&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dJR2So/btsLIb84qRw/9USvcJAgeU2eB3wBgzygWK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdJR2So%2FbtsLIb84qRw%2F9USvcJAgeU2eB3wBgzygWK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;361&quot; height=&quot;350&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bdHbKJ/btsLGXcLf59/qrV4dKpzlsG5OfKDyvEB0K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bdHbKJ/btsLGXcLf59/qrV4dKpzlsG5OfKDyvEB0K/img.png&quot; data-origin-width=&quot;328&quot; data-origin-height=&quot;382&quot; data-is-animation=&quot;false&quot; data-filename=&quot;edited_스크린샷(1383).png&quot; data-widthpercent=&quot;45.43&quot; style=&quot;width: 44.9008%;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bdHbKJ/btsLGXcLf59/qrV4dKpzlsG5OfKDyvEB0K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbdHbKJ%2FbtsLGXcLf59%2FqrV4dKpzlsG5OfKDyvEB0K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;328&quot; height=&quot;382&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맵 프리팹은 생성되면서 부모 오브젝트의 자식 오브젝트들을 전부 가져온 뒤, 리스트에 Transform 을 저장하도록 만들어주었습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1736331715414&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class MapData : MonoBehaviour
{
    //...생략

    private void Awake()
    {
        foreach (Transform t in studentItemSpawnPosParents)
        {
            Transform[] eachPositions = t.GetComponentsInChildren&amp;lt;Transform&amp;gt;();
            foreach(Transform positions in eachPositions)
            {
                if(positions == t) continue;
                studentItemSpawnPos.Add(positions);
            }
        }

        itemSpawnPositionCount = studentItemSpawnPos.Count;

        GameManager.Instance.mapData = this;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아이템을 무작위로 스폰하는 메서드 입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유령 프리팹이 생성된 직후에 이 메서드를 호출하도록 하였습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1736331809440&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//무작위 아이템 스폰
private void SpawnItems()
{
    List&amp;lt;int&amp;gt; spawnIndex = new List&amp;lt;int&amp;gt;(50);
    for(int i = 0; i &amp;lt; GameManager.Instance.mapData.itemSpawnPositionCount; i++)
    {
        int index = Random.Range(0, i + 1);
        spawnIndex.Insert(index, i);
    }

    int maxIndex = DataManager.Instance.GetKeyCount(EDataType.ItemData);

    for (int i = 0; i &amp;lt; GameManager.Instance.mapData.itemSpawnCount; i++)
    {
        int randomIndex = Random.Range(0, maxIndex);
        Transform target = GameManager.Instance.mapData.studentItemSpawnPos[spawnIndex[i]];
        Instantiate(DataManager.Instance.GetDataByInt&amp;lt;ItemData&amp;gt;(randomIndex).ItemPrefab, target.position, Quaternion.identity);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아이템 생성 위치의 길이만큼 int 를 저장하는 List 를 만들면서, 각 인덱스에 0 부터 생성 위치 - 1 까지 값이 무작위로 배치되도록 만들어주었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후, Random 클래스를 통해 무작의 정수값을 얻은 뒤, 데이터 매니저에서 무작위 정수값에 해당되는 인덱스의 아이템을 맵 데이터에 저장된 아이템 생성 갯수만큼 반복하여 무작위로 생성하도록 하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 외에도 빛을 조절하여 분의기를 만들거나, 1 ~ 3분 간격으로 무작위 효과음을 재생하는 기능을 추가하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;트러블슈팅&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;아이템 생성 위치 리스트의 Count가 0으로 나오는 현상&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_스크린샷(1376).png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1042&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bH0j3F/btsLIFn9P6d/q2TfFFtkorWdMcJsEtuNu1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bH0j3F/btsLIFn9P6d/q2TfFFtkorWdMcJsEtuNu1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bH0j3F/btsLIFn9P6d/q2TfFFtkorWdMcJsEtuNu1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbH0j3F%2FbtsLIFn9P6d%2Fq2TfFFtkorWdMcJsEtuNu1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;1042&quot; data-filename=&quot;edited_스크린샷(1376).png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1042&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아이템을 배치할 때, 위치 리스트를 받아올때 0으로 받아오면서 Out of Range 오류가 발생하는 문제가 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 문제의 경우, 맵을 생성하면서 GameManager에 맵 리스트의 프리팹 정보를 저장한 뒤, 저장된 MapData 객체 정보를 기반으로 아이템을 생성하면서 발생한 문제였습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_스크린샷(1379).png&quot; data-origin-width=&quot;1149&quot; data-origin-height=&quot;415&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cVSHu6/btsLHweELwq/LC89fS648bYnjzRjONtn2K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cVSHu6/btsLHweELwq/LC89fS648bYnjzRjONtn2K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cVSHu6/btsLHweELwq/LC89fS648bYnjzRjONtn2K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcVSHu6%2FbtsLHweELwq%2FLC89fS648bYnjzRjONtn2K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1149&quot; height=&quot;415&quot; data-filename=&quot;edited_스크린샷(1379).png&quot; data-origin-width=&quot;1149&quot; data-origin-height=&quot;415&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에서 보신것과 마찬가지로 아이템 생성 위치를 Awake 문에서 추가하는 방식을 사용해주었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 01번 맵 구성이 아이템 생성 위치 오브젝트가 40개가 넘어가기때문에 해당 방식을 사용하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 결과, Awake문이 작동하지 않은 MapData를 사용하여 아이템 생성 시도시, 아이템 생성 위치 리스트는 0 인 상태로 생성을 시도하게 되어 문제가 발생하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 MapData의 Awake 문의 마지막에 GameManager 에 저장된 MapData 를 자신으로 변경하도록 만들어서 해당 문제를 해결하였습니다.&lt;/p&gt;</description>
      <category>내일배움캠프/프로젝트</category>
      <author>서보훈</author>
      <guid isPermaLink="true">https://gamerscode.tistory.com/144</guid>
      <comments>https://gamerscode.tistory.com/144#entry144comment</comments>
      <pubDate>Wed, 8 Jan 2025 20:22:33 +0900</pubDate>
    </item>
    <item>
      <title>팀 프로젝트 - 프리팹  용량문제</title>
      <link>https://gamerscode.tistory.com/143</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;기존 프리팹에 UModeler를 사용하여 문 부분을 잘라내고, 해당 부분을 문으로 사용하려 하였으나, 해당 작업 후 깃 허브에 올리는 과정에서 프리팹의 용량이 너무 커서 올리지 못하는 문제가 발생하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 문제의 원인을 찾던중, UModelerize 로 변환한 프리팹이 원본 프리팹보다 과도하게 용량이 큰점을 발견하였습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_edited_스크린샷(1364).png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1037&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfPAtZ/btsLHomEf4P/SHACsDJKurFp8ZRpsQNEIK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfPAtZ/btsLHomEf4P/SHACsDJKurFp8ZRpsQNEIK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfPAtZ/btsLHomEf4P/SHACsDJKurFp8ZRpsQNEIK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbfPAtZ%2FbtsLHomEf4P%2FSHACsDJKurFp8ZRpsQNEIK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;1037&quot; data-filename=&quot;edited_edited_스크린샷(1364).png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1037&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원본 용량이 322KB 인데 반해, UModeler로 변환한 파일의 용량이 90,000KB를 넘는 모습입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제의 원인은 사용중인 모델이 프리팹을 조합해둔 형태였기 때문에 발생하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원본의 경우, 하나의 프리팹을 여러번 사용하여서 용량이 크지 않았지만, UModeler로 변환한 모델의 경우 같은 형태의 오브젝트도 각각 다른 매쉬를 사용하고 있는것을 발견하였습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_스크린샷(1369).png&quot; data-origin-width=&quot;1734&quot; data-origin-height=&quot;285&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cUkEfE/btsLFfELOI9/wKAK0kcSXOUyqHsk34RI00/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cUkEfE/btsLFfELOI9/wKAK0kcSXOUyqHsk34RI00/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cUkEfE/btsLFfELOI9/wKAK0kcSXOUyqHsk34RI00/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcUkEfE%2FbtsLFfELOI9%2FwKAK0kcSXOUyqHsk34RI00%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1734&quot; height=&quot;285&quot; data-filename=&quot;edited_스크린샷(1369).png&quot; data-origin-width=&quot;1734&quot; data-origin-height=&quot;285&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_스크린샷(1370).png&quot; data-origin-width=&quot;1727&quot; data-origin-height=&quot;286&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WooqF/btsLG3wiuKh/s3Gdkt43zO8J6uBCCsFGEk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WooqF/btsLG3wiuKh/s3Gdkt43zO8J6uBCCsFGEk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WooqF/btsLG3wiuKh/s3Gdkt43zO8J6uBCCsFGEk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWooqF%2FbtsLG3wiuKh%2Fs3Gdkt43zO8J6uBCCsFGEk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1727&quot; height=&quot;286&quot; data-filename=&quot;edited_스크린샷(1370).png&quot; data-origin-width=&quot;1727&quot; data-origin-height=&quot;286&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 중복되는 매쉬로인해 프리팹의 용량이 늘어나게 되었고, 100MB를 넘어서 깃허브에 커밋이 되지 않는 문제가 발생하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 해결책을 생각하던중, 건물이 파츠로 전부 분리되어있다는점을 이용, 문을 만들 벽 부분을 UModeler로 분리한 오브젝트로 변경하여 사용하는것으로 용량문제를 해결하였습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/M3w3t/btsLFRKdzlh/aFfjlJaP792ZCd30DbDQp1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/M3w3t/btsLFRKdzlh/aFfjlJaP792ZCd30DbDQp1/img.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1038&quot; data-is-animation=&quot;false&quot; data-filename=&quot;edited_스크린샷(1373).png&quot; style=&quot;width: 49.4662%; margin-right: 10px;&quot; data-widthpercent=&quot;50.05&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/M3w3t/btsLFRKdzlh/aFfjlJaP792ZCd30DbDQp1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FM3w3t%2FbtsLFRKdzlh%2FaFfjlJaP792ZCd30DbDQp1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;1038&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Cyv3f/btsLGcAt9Uj/gzkmujLfavR3Dteq25SBfk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Cyv3f/btsLGcAt9Uj/gzkmujLfavR3Dteq25SBfk/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot; data-filename=&quot;edited_스크린샷(1374).png&quot; style=&quot;width: 49.371%;&quot; data-widthpercent=&quot;49.95&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Cyv3f/btsLGcAt9Uj/gzkmujLfavR3Dteq25SBfk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCyv3f%2FbtsLGcAt9Uj%2FgzkmujLfavR3Dteq25SBfk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 건축물의 Wall_A(1) 부분을 문으로 만들기 위해 비활성화를 해준 후,UModeler로 분리해준 문 파츠를 넣어주었습니다&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_스크린샷(1372).png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1038&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dQF8Lt/btsLGZ1G2dp/cA8Aluu527E9zarP04S9g0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dQF8Lt/btsLGZ1G2dp/cA8Aluu527E9zarP04S9g0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dQF8Lt/btsLGZ1G2dp/cA8Aluu527E9zarP04S9g0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdQF8Lt%2FbtsLGZ1G2dp%2FcA8Aluu527E9zarP04S9g0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;1038&quot; data-filename=&quot;edited_스크린샷(1372).png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1038&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 건축물에 해당 작업을 시행하였으며, 문제없이 커밋이 되는것을 확인하였습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 문제로 인해 기존에 작업했던 지형이나 문 관련 구현 내용을 사용하지 못하게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 커밋을 여러번 한 후 한번에 push를 시도했던것이 큰 화근이 되었으며, 앞으로는 작업후 즉시 push를 시도하여 문제가 커지기 전에 잡아야한다는것을 느꼈습니다.&lt;/p&gt;</description>
      <category>내일배움캠프/프로젝트</category>
      <author>서보훈</author>
      <guid isPermaLink="true">https://gamerscode.tistory.com/143</guid>
      <comments>https://gamerscode.tistory.com/143#entry143comment</comments>
      <pubDate>Tue, 7 Jan 2025 20:17:11 +0900</pubDate>
    </item>
  </channel>
</rss>