본문 바로가기
Development/C#

멋쟁이사자처럼부트캠프 Unity 게임 개발 3기 31일차

by Mobics 2025. 1. 13.

 

목차


    Design Pattern

    25.01.03

    Singleton

    : 클래스가 자신의 인스턴스 하나만 인스턴스화 할 수 있도록 보장해준다. --> 해당 단일 인스턴스에 대한 손쉬운 전역 엑세스를 제공한다.

    >> 하나의 객체만 존재할 필요가 있을 때 사용된다. ex) 상태창, 게임 매니저, 오디오 매니저, 파일 관리자, UI Setting 등등

     

    >> Singleton.cs

    using UnityEngine;
    
    // T는 MonoBehaviour를 상속하는 형식만 가능
    public abstract class Singleton<T> : MonoBehaviour where T : MonoBehaviour
    {
        private static T instance;
    
        public static T Instance
        {
            get
            {
                // 강사님은 이거 생략하신다.
                if (instance == null)
                {
                    instance = FindObjectOfType(typeof(T)) as T; // 현재 Scene에 존재하는 T 타입 객체를 검색
                }
    
                if (instance == null) // 검색했음에도 null
                {
                    GameObject obj = new GameObject(); // 새 GameObject 생성
                    obj.name = typeof(T).Name; // GameObject 이름을 T의 이름으로 설정
                    obj.AddComponent<T>(); // T 타입의 Component 추가
                }
    
                return instance;
            }
        }
    
        private void Awake()
        {
            // instance가 null이면 현재 객체를 Singleton으로 설정
            if (instance == null)
                instance = this as T;
    
            OnAwake(); // Singleton을 상속받는 자식 클래스가 초기화 코드를 작성할 수 있도록 호출
        }
    
        public virtual void OnAwake() { }
    }

     

    >> 참고 영상

    https://youtube.com/watch?v=Tf_VZEgnag0

     

    ※ 게임이 시작되면 그냥 처음으로 불리우는 함수

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    public static void OnBeforeSceneLoadRuntimeMethod()
    {
        
    }

    Strategy Pattern (전략 패턴)을 이용한 턴제 조종하기

    Player Input에서 Actions 추가 (Number_1, Number_2)

     

    Singleton + Observer Pattern

    기본 입력 이벤트를 Interface로 정의

    >> IReceiveInput.cs

    using UnityEngine;
    
    public interface IReceiveInput
    {
        public void OnTriggered(string action, bool isTrigger);
        public void OnReadValue(string action, Vector2 value);
    }

     

    플레이어의 상태 및 입력 관리

    >> PlayerController.cs

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    public class PlayerController : Singleton<PlayerController>
    {
        private Dictionary<string, GameObject> players = new();
        private Dictionary<GameObject, IReceiveInput> inputs = new();
        private GameObject currentPlayer;
    
        IEnumerator Start()
        {
            yield return new WaitForSeconds(3.0f);
            ChangePlayer("Number_1");
        }
        
        public void AddObserver(string key, GameObject obj)
        {
            players.Add(key, obj);
        }
    
        public void AddInputObserver(GameObject obj, IReceiveInput input)
        {
            inputs[obj] = input;
        }
    
        private bool ChangePlayer(string key)
        {
            if (players.TryGetValue(key, out var player))
            {
                currentPlayer = player;
                return true;
            }
            
            return false;
        }
        
        public void OnTriggered(string action, bool isTrigger)
        {
            if (isTrigger && ChangePlayer(action))
                return;
            
            if (!currentPlayer)
                return;
            
            if (ContainsInput())
            {
                inputs[currentPlayer].OnTriggered(action, isTrigger);
            }
        }
        
        public void OnReadValue(string action, Vector2 value)
        {
            if (!currentPlayer)
                return;
            
            if (ContainsInput())
            {
                inputs[currentPlayer].OnReadValue(action, value);
            }
        }
    
        private bool ContainsInput()
        {
            return inputs.ContainsKey(currentPlayer) && inputs[currentPlayer] != null;
        }
    }

     

    >> CustomTag.cs

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    public class CustomTag : MonoBehaviour
    {
        public string tag;
        void Start()
        {
            PlayerController.Instance.AddObserver(tag, gameObject);
        }
    }

     

    Unity Input System과 연동

    >> MyInputManager.cs

    : Input에 대한 관리 --> 매 프레임마다 입력된 값을 PlayerController에 있는 OnReadValue, OnTriggered를 통해 호출하여 실행한다.

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.InputSystem;
    
    public class MyInputManager : Singleton<MyInputManager>
    {
        private InputAction moveInput;
        private InputAction jumpInput;
    
        private InputAction number_1;
        private InputAction number_2;
        
        void Start()
        {
            PlayerInput playerInput = GetComponent<PlayerInput>();
            moveInput = playerInput.actions["Move"];
            jumpInput = playerInput.actions["Jump"];
    
            number_1 = playerInput.actions["Number_1"];
            number_2 = playerInput.actions["Number_2"];
        }
    
        void Update()
        {
            var playerController = PlayerController.Instance;
            
            playerController.OnReadValue("Move", moveInput.ReadValue<Vector2>());
            playerController.OnTriggered("Jump", jumpInput.triggered);
            playerController.OnTriggered("Number_1", number_1.triggered);
            playerController.OnTriggered("Number_2", number_2.triggered);
        }
    }

     

    >> Blackboard_Default.cs

    : MyInputManager.cs에서 Input을 관리하므로 moveInput과 jumpInput 삭제

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    public class Blackboard_Default : MonoBehaviour, IBlackboardBase
    {
        public Animator animator;
        public Rigidbody rigidbody;
        
        public float moveSpeed = 3.0f;
        public float jumpForce = 3.0f;
        
        public void InitBlackboard()
        {
            animator = GetComponent<Animator>();
            rigidbody = GetComponent<Rigidbody>();
        }
    }

     

    캐릭터 움직임 제어

    >> IdleState.cs

    : Input을 관리하는 주체가 Blackboard에서 InputManager로 바뀌면서 전체적인 코드 수정

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.InputSystem;
    
    public class IdleState : MonoBehaviour, IState, IReceiveInput
    {
        public StateMachine Fsm { get; set; }
        public Blackboard_Default Blackboard { get; set; }
    
        private bool jumpInputTriggered = false;
        private Vector2 moveInput = Vector2.zero;
    
        public void InitState(IBlackboardBase blackboard)
        {
            Blackboard = blackboard as Blackboard_Default;
        }
    
        public void Enter()
        {
            PlayerController.Instance.AddInputObserver(Fsm.gameObject, this);
            PlayerController.Instance.AddInputObserver(Fsm.gameObject, this);
            
            Blackboard.animator.CrossFade("Idles", 0.1f);
            Blackboard.animator.SetFloat("Speed", 0.0f);
        }
    
        public void UpdateState(float deltaTime)
        {
            if (jumpInputTriggered && Blackboard.rigidbody.velocity.y == 0.0f)
            {
                Fsm.ChangeState<JumpState>();
                return;
            }
            
            if (moveInput.sqrMagnitude > 0)
            {
                Fsm.ChangeState<WalkState>();
            }
    
        }
    
        public void Exit()
        {
            PlayerController.Instance.AddInputObserver(Fsm.gameObject, null);
            PlayerController.Instance.AddInputObserver(Fsm.gameObject, null);
            
            jumpInputTriggered = false;
            moveInput = Vector2.zero;
        }
    
        public void OnTriggered(string action, bool isTrigger)
        {
            if (action == "Jump")
                jumpInputTriggered = isTrigger;
        }
    
        public void OnReadValue(string action, Vector2 value)
        {
            if (action == "Move")
                moveInput = value;
        }
    }

     

    >> WalkState.cs

    : Input을 관리하는 주체가 Blackboard에서 InputManager로 바뀌면서 전체적인 코드 수정

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.InputSystem;
    
    public class WalkState : MonoBehaviour, IState, IReceiveInput
    {
        public StateMachine Fsm { get; set; }
        public Blackboard_Default Blackboard { get; set; }
    
        private bool jumpInputTriggered = false;
        private Vector2 moveInput = Vector2.zero;
    
        public void InitState(IBlackboardBase blackboard)
        {
            Blackboard = blackboard as Blackboard_Default;
        }
    
        public void Enter()
        {
            PlayerController.Instance.AddInputObserver(Fsm.gameObject, this);
            PlayerController.Instance.AddInputObserver(Fsm.gameObject, this);
            
            Blackboard.animator.CrossFade("Idles", 0.1f);
            Blackboard.animator.SetFloat("Speed", 1.0f);
        }
    
        public void UpdateState(float deltaTime)
        {
            if (jumpInputTriggered && Blackboard.rigidbody.velocity.y == 0.0f)
            {
                Fsm.ChangeState<JumpState>();
                return;
            }
            
            if (0 >= moveInput.sqrMagnitude)
            {
                Fsm.ChangeState<IdleState>();
                return;
            }
            
            Blackboard.rigidbody.velocity = new Vector3(moveInput.x * Blackboard.moveSpeed, Blackboard.rigidbody.velocity.y, moveInput.y * Blackboard.moveSpeed);
        }
        
        public void Exit()
        {
            PlayerController.Instance.AddInputObserver(Fsm.gameObject, null);
            PlayerController.Instance.AddInputObserver(Fsm.gameObject, null);
            
            jumpInputTriggered = false;
            moveInput = Vector2.zero;
        }
    
        public void OnTriggered(string action, bool isTrigger)
        {
            
        }
    
        public void OnReadValue(string action, Vector2 value)
        {
            if (action == "Move")
                moveInput = value;
        }
    }

     

    └ Unity Setting

    >> MyChan 캐릭터 2개로 변경 및 Component 수정

    : MyInputManager로 따로 만들어줬기 때문에 PlayerInput 제거

     

    >> PlayerController, MyInputManager 추가

     

    스스로 Enum 코드 만들어내기

    : Attribute는 크게 신경 안 써도 된다. --> 할 줄 알면 회사에서 칭찬 받는다, 퇴근 시간이 빨라진다, 자동화 할 수 있다 정도

     

    >> StateManagerGeneratorWindow.cs

    using System;
    using System.Collections;
    using System.Collections.Generic;
    using System.IO;
    using System.Linq;
    using System.Reflection;
    using System.Text;
    using UnityEditor;
    using UnityEngine;
    using Directory = UnityEngine.Windows.Directory;
    
    public class StateManagerGeneratorWindow : EditorWindow
    {
        [MenuItem("Tools/State Manager Generator")]
        public static void ShowWindow()
        {
            GetWindow<StateManagerGeneratorWindow>("State Manager Generator");
        }
    
        private void OnGUI()
        {
            if (GUILayout.Button("Generate"))
            {
                Dictionary<string, List<Type>> enums = new();
            
                var assemblies = AppDomain.CurrentDomain.GetAssemblies();
                foreach (var assembly in assemblies)
                {
                    var types = assembly.GetTypes().Where(t => t.GetCustomAttributes(typeof(StateAttribute), true).Length > 0);
    
                    foreach (var type in types)
                    {
                        var attr = type.GetCustomAttribute<StateAttribute>();
                        if (attr != null && !string.IsNullOrEmpty(attr.StateName))
                        {
                            if (!enums.ContainsKey(attr.StateName))
                            {
                                enums.Add(attr.StateName, new List<Type>());
                            }
                            enums[attr.StateName].Add(type);
                        }
                    }
                }
    
                var List = enums.Keys.OrderBy(s => s).ToList();
                var savePath = "Assets/Scripts/Generated";
                StringBuilder sb = new StringBuilder();
    
                if (!Directory.Exists(savePath))
                {
                    Directory.CreateDirectory(savePath);
                }
    
                sb.AppendLine("using System;");
                sb.AppendLine("using System.Collections.Generic;");
                
                sb.AppendLine($"public class StateTypesClasses");
                sb.AppendLine("{");
                GenerateFillEnumText(sb, List);
    
                sb.AppendLine("\tprivate static readonly Dictionary<Type, StateTypes> TypeToState = new()");
                sb.AppendLine("\t{");
                
                foreach (var se in List)
                {
                    var orderedList = enums[se].OrderBy(t => t.Name).ToList();
                    foreach (var type in orderedList)
                    {
                        sb.AppendLine($"\t\t[typeof({type})] = StateTypes.{se},");
                    }
                }
                
                sb.AppendLine("\t};");
                
                sb.AppendLine("\tpublic static StateTypes GetState<T>() => GetState(typeof(T));");
                sb.AppendLine("\tpublic static StateTypes GetState(Type type)");
                sb.AppendLine("\t{");
                sb.AppendLine("\t\treturn TypeToState.GetValueOrDefault(type, StateTypes.None);");
                sb.AppendLine("\t}");
                
                sb.AppendLine("}");
                
                string filePath = Path.Combine(savePath, "StateTypesClasses.cs");
                File.WriteAllText(filePath, sb.ToString());
            }
        }
        
        private static void GenerateFillEnumText(StringBuilder sb, List<string> List)
        {
            sb.AppendLine($"\tpublic enum StateTypes");
            sb.AppendLine("\t{");
            sb.AppendLine("\t\tNone,");
            foreach (var se in List)
            {
                sb.AppendLine($"\t\t{se},");
            }
            sb.AppendLine("\t\tMax");
            sb.AppendLine("\t}");
        }
    }

     

    >> StateAttribute.cs

    using System;
    
    [AttributeUsage(AttributeTargets.Class)]
    public class StateAttribute : Attribute
    {
        public string StateName { get; private set; }
    
        public StateAttribute(string stateName)
        {
            StateName = stateName;
        }
    }

     

    >> StateMachine.cs

    using System;
    using System.Collections.Generic;
    using Unity.VisualScripting;
    using UnityEngine;
    using STC = StateTypesClasses; // 추가
    
    public enum StaterType
    {
        None,
        Character,
        Max
    }
    
    public static class StateFactory
    {
        public static List<IState> CreateStates(this StateMachine stateMachine, StaterType staterType)
        {
            List<IState> stateList = new List<IState>();
            
            switch (staterType)
            {
                case StaterType.Character:
                {
                    stateList.Add(stateMachine.AddComponent<IdleState>());
                    stateList.Add(stateMachine.AddComponent<WalkState>());
                    stateList.Add(stateMachine.AddComponent<JumpState>());
                }
                    break;
            }
    
            return stateList;
        }
    }
    
    public class StateMachine : MonoBehaviour
    {
        [SerializeField]private string defaultState;
        
        private IState currentState;
        private Dictionary<STC.StateTypes, IState> stateDict = new(); // 변경
    
        public void Run()
        {
            IBlackboardBase blackboardBase = GetComponent<IBlackboardBase>();
            blackboardBase.InitBlackboard();
            List<IState> states = this.CreateStates(StaterType.Character);
            foreach (var state in states)
            {
                AddState(state, blackboardBase);
            }
            
            ChangeState(Type.GetType(defaultState));
        }
    
        public void AddState(IState state, IBlackboardBase blackboard)
        {
            state.Fsm = this;
            state.InitState(blackboard);
            stateDict.Add(STC.GetState(state.GetType()), state); // 변경
        }
    
        public void ChangeState<T>() where T : IState
        {
            ChangeState(typeof(T));
        }
        
        public void ChangeState(Type stateType) // 변경
        {
            ChangeState(STC.GetState(stateType));
        }
    
        public void ChangeState(STC.StateTypes stateType) // 추가
        {
            currentState?.Exit();
    
            if (!stateDict.TryGetValue(stateType, out currentState)) return;
            
            currentState?.Enter();
        }
    
        public void UpdateState()
        {
            if (currentState != null)
                currentState.UpdateState(Time.deltaTime);
        }
    }

     

    >> 각 State 수정

    - IdleState.cs

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.InputSystem;
    
    [State("IdleState")] // 추가
    public class IdleState : MonoBehaviour, IState, IReceiveInput
    {
        public StateMachine Fsm { get; set; }
        public Blackboard_Default Blackboard { get; set; }
    
        private bool jumpInputTriggered = false;
        private Vector2 moveInput = Vector2.zero;
    
        public void InitState(IBlackboardBase blackboard)
        {
            Blackboard = blackboard as Blackboard_Default;
        }
    
        public void Enter()
        {
            PlayerController.Instance.AddInputObserver(Fsm.gameObject, this);
            PlayerController.Instance.AddInputObserver(Fsm.gameObject, this);
            
            Blackboard.animator.CrossFade("Idles", 0.1f);
            Blackboard.animator.SetFloat("Speed", 0.0f);
        }
    
        public void UpdateState(float deltaTime)
        {
            if (jumpInputTriggered && Blackboard.rigidbody.velocity.y == 0.0f)
            {
                Fsm.ChangeState<JumpState>();
                return;
            }
            
            if (moveInput.sqrMagnitude > 0)
            {
                Fsm.ChangeState<WalkState>();
            }
    
        }
    
        public void Exit()
        {
            PlayerController.Instance.AddInputObserver(Fsm.gameObject, null);
            PlayerController.Instance.AddInputObserver(Fsm.gameObject, null);
            
            jumpInputTriggered = false;
            moveInput = Vector2.zero;
        }
    
        public void OnTriggered(string action, bool isTrigger)
        {
            if (action == "Jump")
                jumpInputTriggered = isTrigger;
        }
    
        public void OnReadValue(string action, Vector2 value)
        {
            if (action == "Move")
                moveInput = value;
        }
    }

     

    - WalkState.cs

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.InputSystem;
    
    [State("WalkState")] // 추가
    public class WalkState : MonoBehaviour, IState, IReceiveInput
    {
        public StateMachine Fsm { get; set; }
        public Blackboard_Default Blackboard { get; set; }
    
        private bool jumpInputTriggered = false;
        private Vector2 moveInput = Vector2.zero;
    
        public void InitState(IBlackboardBase blackboard)
        {
            Blackboard = blackboard as Blackboard_Default;
        }
    
        public void Enter()
        {
            PlayerController.Instance.AddInputObserver(Fsm.gameObject, this);
            PlayerController.Instance.AddInputObserver(Fsm.gameObject, this);
            
            Blackboard.animator.CrossFade("Idles", 0.1f);
            Blackboard.animator.SetFloat("Speed", 1.0f);
        }
    
        public void UpdateState(float deltaTime)
        {
            if (jumpInputTriggered && Blackboard.rigidbody.velocity.y == 0.0f)
            {
                Fsm.ChangeState<JumpState>();
                return;
            }
            
            if (0 >= moveInput.sqrMagnitude)
            {
                Fsm.ChangeState<IdleState>();
                return;
            }
            
            Blackboard.rigidbody.velocity = new Vector3(moveInput.x * Blackboard.moveSpeed, Blackboard.rigidbody.velocity.y, moveInput.y * Blackboard.moveSpeed);
        }
        
        public void Exit()
        {
            PlayerController.Instance.AddInputObserver(Fsm.gameObject, null);
            PlayerController.Instance.AddInputObserver(Fsm.gameObject, null);
            
            jumpInputTriggered = false;
            moveInput = Vector2.zero;
        }
    
        public void OnTriggered(string action, bool isTrigger)
        {
            
        }
    
        public void OnReadValue(string action, Vector2 value)
        {
            if (action == "Move")
                moveInput = value;
        }
    }

     

    - JumpState.cs

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    [State("JumpState")] // 추가
    public class JumpState : MonoBehaviour, IState
    {
        public StateMachine Fsm { get; set; }
        public Blackboard_Default Blackboard { get; set; }
    
        public void InitState(IBlackboardBase blackboard)
        {
            Blackboard = blackboard as Blackboard_Default;
        }
    
        public void Enter()
        {
            Blackboard.animator.CrossFade("Jump", 0.1f);
            Blackboard.rigidbody.velocity = new Vector3(Blackboard.rigidbody.velocity.x, Blackboard.jumpForce, Blackboard.rigidbody.velocity.z);
        }
    
        public void UpdateState(float deltaTime)
        {
            if (Blackboard.rigidbody.velocity.y == 0.0f)
            {
                Fsm.ChangeState<IdleState>();
            }
        }
    
        public void Exit()
        {
            
        }
    }

     

    State Manager Generator 작동하기

     

    - 작동하면 StateTypesClasses.cs가 자동으로 생성됨

     

    >> StateTypesClasses.cs

    using System;
    using System.Collections.Generic;
    
    public class StateTypesClasses
    {
        public enum StateTypes
        {
            None,
            IdleState,
            JumpState,
            WalkState,
            Max
        }
        
        private static readonly Dictionary<Type, StateTypes> TypeToState = new()
        {
            [typeof(IdleState)] = StateTypes.IdleState,
            [typeof(JumpState)] = StateTypes.JumpState,
            [typeof(WalkState)] = StateTypes.WalkState,
        };
        public static StateTypes GetState<T>() => GetState(typeof(T));
        public static StateTypes GetState(Type type)
        {
            return TypeToState.GetValueOrDefault(type, StateTypes.None);
        }
    }

     

    └ 문제 발생

    : 코드는 다 그대로 작성했는데, StateAttribute.cs의 문제인건지 무언갈 놓친게 있는건지 스크립트 상에서 사진과 같이 에러가 발생한다.

    ※ IdleState.cs 뿐만 아니라 WalkState.cs, JumpState.cs도 마찬가지

     

    그래서 State Manager Generator로 StateTypesClasses.cs를 생성해도 다음과 같이 생성된다.

    using System;
    using System.Collections.Generic;
    public class StateTypesClasses
    {
    	public enum StateTypes
    	{
    		None,
    		Max
    	}
    	private static readonly Dictionary<Type, StateTypes> TypeToState = new()
    	{
    	};
    	public static StateTypes GetState<T>() => GetState(typeof(T));
    	public static StateTypes GetState(Type type)
    	{
    		return TypeToState.GetValueOrDefault(type, StateTypes.None);
    	}
    }

     

    강사님 깃을 통해 프로젝트를 다운받아서 열어보면 아래 사진과 같이 문제 없이 작동한다.

     

    사실 이 부분은 너무 어려워서 코드부터 이해하질 못 했기 때문에 문제 해결 방법도 잘 모르겠다..


    Design Pattern, 참고할 Youtube 영상

    ※ Unity Korea에서 알려주는 패턴이 되게 설명을 잘 해준다!

     

    >> 디자인 패턴의 기초, SOLID 원칙 이해하기

    https://www.youtube.com/watch?v=J6F8plGUqv8&t=12s

    >> Singleton

    https://www.youtube.com/watch?v=Tf_VZEgnag0

    >> Command Pattern

    https://www.youtube.com/watch?v=oLRINAn0cuw

    >> State Machine

    https://www.youtube.com/watch?v=Vt8aZDPzRjI&t=993s