[카테고리:] Project

  • 유니티 설계 경험 기록

    [Unity] 유니티 설계 경험 기록 — AlgorFati의 개발 기록 (tistory.com)

    유니티 설계 경험

    유니티를 이용하면서 내가 경험한 내용을 정리한 글이다. 이 글은 유니티를 개발하는 과정 속에서 지속적으로 업데이트 될 예정이다.
     

    핵심 개념

    다음 개념들은 알고 시작하는 것이 좋다.
     

    유니티 버전단위 문서

    유니티 버전에 따른 매뉴얼이다. 이 문서를 통해 대부분의 유니티 관련한 이용방법을 숙지할 수 있다.
    https://docs.unity3d.com/2021.2/Documentation/Manual/index.htmlUnity – Manual:  Unity User Manual 2021.2Use the Unity Editor to create 2D and 3D games, apps and experiences. Download the Editor at unity3d.com. The Unity User Manual helps you learn how to use the Unity Editor and its associated services. You can read it from start to finish, or use it as a redocs.unity3d.com

    유니티 핵심 객체

    유니티로 개발을 시작하기 전에 반드시 숙지해야하는 객체들에 대한 문서이다.
    https://docs.unity3d.com/Manual/ScriptingImportantClasses.htmlUnity – Manual:  Important ClassesNull Reference Exceptions Important Classes – GameObject Important Classes This section provides an overview of some of the most commonly used and important built-in classes in Unity that you may want to use when scripting. These pages serve as a startingdocs.unity3d.com

    유니티 이벤트 플로우

    유니티는 코어 시스템이 모두 C++로 개발되어있고 이 부분은 유니티 C# 개발자들에게는 감춰져있다. 그렇기 때문에 유니티에서 여러 코어 로직들이 실행되는 타이밍을 C# 에서 활용할 수 있는 인터페이스가 필요하다. 예를 들면, 모든 물리 로직이 끝난 직후, 모든 렌더링 작업이 끝난 직후 등등과 같은 타이밍들이 있을 것이다. 다음 링크는 이 이벤트를 정리해둔 문서이다.
    https://docs.unity3d.com/Manual/ExecutionOrder.htmlUnity – Manual:  Order of execution for event functionsInstantiating Prefabs at run time Order of execution for event functions Running a Unity script executes a number of event functions in a predetermined order. This page describes those event functions and explains how they fit into the execution sequence.docs.unity3d.com

    유니티 아키텍쳐

    유니티에 전반적인 설계 구조에 대한 문서이다.
    https://docs.unity3d.com/Manual/unity-architecture.htmlUnity – Manual:  Unity architectureImportant Classes – Gizmos & Handles Overview of .NET in Unity Unity architecture The Unity engine is built with native C/C++ internally, however it has a C# wrapper that you use to interact with it. As such, you need to be familiar with some of the key codocs.unity3d.com

    설계 규칙

    개인적으로 유니티를 사용하면서 정리한 설계 규칙이다.
     

    유니티 C# 프로그래밍 컨벤션은 Microsoft C# 컨벤션을 이용한다.

    https://docs.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventionsC# Coding ConventionsLearn about coding conventions in C#. Coding conventions create a consistent look to the code and facilitate copying, changing, and maintaining the code.docs.microsoft.com

    프로젝트의 커스텀 Enum 데이터 타입들을 모아놓을 클래스 파일을 생성한다.

    유니티 개발 과정에서 다양한 열거형 타입들을 사용하게 될 것이다. 이 데이터 타입들 중에서는 특정 클래스에 귀속되어야 하는 타입도 존재하겠지만 글로벌하게 사용되어야 하는 공통 데이터 타입들도 존재한다. 빠르게 개발을 진행할 때를 위해 마음놓고 정의할 공간이 필요하다. 타입들을 네임스페이스로 묶어두면 클래스 타입과 네이밍이 겹칠 일이 없으며, 자동완성의 이점도 누릴 수 있다.

    namespace EnumTypes
    {
    	public enum AttackTypes
    	{
    		None, Melee, Range
    	}
    
    	public enum CardRanks
    	{
    		Normal, Special, Rare
    	}
    
    	public enum CardHowToUses
    	{
    		Normal, TargetGround, TargetEntity
    	}
    
    	public enum CardAfterUses
    	{
    		Discard, Destruct, Spawn
    	}
    
    	public enum GameFlowState
    	{
    		InitGame, SelectStage, Setting, Wave, EventFlow, Ending
    	}
    }

    프로젝트의 커스텀 Struct 데이터 타입들을 모아놓을 클래스 파일을 생성한다.

    열거형 타입과 마찬가지의 이유로 구조체 타입들을 모아놓을 공간이 필요하다.
     

    namespace Structs
    {
    	[Serializable]
    	public struct AttackData
    	{
    		public AttackTypes attackType;
    		public int attackAnimationIndex;
    
    		public ScriptableObjects.MeleeTrace meleeTrace;
    		public ScriptableObjects.Projectile projectile;
    	}
    
    	[Serializable]
    	public struct StatModifierData
    	{
    		public StatTypes statType;
    		public ModifierTypes modifierType;
    		public float value;
    	}
    
    	//..
    }

    프로젝트의 유틸리티 함수를 모아놓을 클래스를 생성한다.

    언리얼에서 작업을 진행하게 되면, 굉장히 많은 기능들과 방대한 코드 속에서 그때그때 예제를 만들며 많은 시간을 낭비하게 된다. 시간을 좀 더 효율적으로 활용하기 위해 제작해본 예제들을 기능 단위로 유틸리티 함수에 쌓아두어야 할 것이다. 유틸리티 함수는 일반화된 작업들을 수행하는 함수들로, 모두 static함수로 구성되어 있고 클래스 또한 static class로 정의한다. 이렇게 하면 인스턴싱으로 인한 부분은 아예 신경쓸 일이 없을 것이다.

    public static class Utils
    {
    	public static void SetTimeScale(float timescale)
    	{
    		Time.timeScale = timescale;
    		Time.fixedDeltaTime = 0.02f * Time.timeScale;
    	}
    
    	public static int GenerateID<T>()
    	{
    		return GenerateID(typeof(T));
    	}
    	public static int GenerateID(System.Type type)
    	{
    		return Animator.StringToHash(type.Name);
    	}
    
    	public static float DirectionToAngle(float x, float y)
    	{
    		float cos = x;
    		float sin = y;
    		return Mathf.Atan2(sin, cos) * Mathf.Rad2Deg;
    	}
        // ..
    }

    프로젝트의 글로벌 변수들을 모아놓을 클래스를 정의한다.

    개발 과정중에는 종종 글로벌 변수를 이용해야 하는 상황이 생긴다. 특히 문자열 같은 경우 여러 클래스에 걸쳐 같은 문자열이 사용된다면 이를 글로벌 변수로 정의하여 공유하도록 하는것이 현명하다. 단, 여기서 말하는 글로벌 변수들은 변하지 않는 변수들을 의미하므로 모두 const와 readonly로 정의한다. (값이 변할 수 있는 글로벌 변수는 되도록이면 이용하지 않도록 한다.) 그리고 클래스는 Utils와 마찬가지의 이유로 클래스와 변수를 static으로 정의한다. 

    public static class Globals
    {
    	public const int WorldSpaceUISortingOrder = 1;
    	public const int CharacterStartSortingOrder = 10;
    
    	public static class LayerName
    	{
    		public static readonly string Default = "Default";
    		public static readonly string UI = "UI";
    		public static readonly string Card = "Card";
    		public static readonly string Obstacle = "Obstacle";
    	}
        
        // ..
    }

    생성될 객체들을 관리할 관리자 클래스를 정의한다.

    게임 내에 정의될 모든 객체들을 Child로 갖는 하나의 관리자 클래스를 정의한다. 만약 관리자 클래스가 여러 개가 정의되어야 한다면 꼭대기의 가장 핵심적인 관리자 클래스를 두고 그 Child로 파생 관리자 객체들을 두면 된다. 아래 예제는 가장 꼭대기에 GameManager 클래스가 하위 관리자로 CharacterManager, UIManager, CameraController, MapManager, EffectManager를 갖는 형태로 설계되어 있다. 이렇게 설계를 해 두고 GameManager에 대한 역참조만 정의해둔다면, 모든 클래스가 다른 모든 클래스에 접근할 수 있게 된다. 이 클래스는 Scene의 가장 루트 GameObject로 단 하나만 미리 인스턴싱 해두도록 한다.

    public class GameManager : MonoBehaviour, IGameManager
    {
    	[SerializeField]
    	private CharacterManager _characterManager;
    
    	[SerializeField]
    	private UIManager _uiManager;
    
    	[SerializeField]
    	private CameraController _cameraController;
    
    	[SerializeField]
    	private MapManager _mapManager;
    
    	[SerializeField]
    	private EffectManager _effectManager;
        
        // ..
    }

     
    GameManager의 하위 관리자는 GameManager로의 역참조를 갖도록 한다.

    public class CharacterManager : MonoBehaviour
    {
    	private IGameManager _gameManager;
    	private List<BaseCharacter> _guardians = new List<BaseCharacter>();
        
    	public IGameManager GameManager
    	{
    		get { return _gameManager; }
    	}
    	
    	public void Init(IGameManager gameManager)
    	{
    		_gameManager = gameManager;
    	}
    	// ..
    }

     
    CharacterManager의 하위 객체는 CharacterManager로의 역참조를 갖는다. 이렇게 되면 모든 객체는 다른 모든 객체를 참조해올 수 있다.

    public class BaseCharacter : MonoBehaviour
    {
    	protected CharacterManager _manager;
    	public void Init(CharacterManager manager)
    	{
    		_manager = manager;
    	}
    }

     
    시니어 개발자 중에는 관리자 클래스에 대해 부정적인 의견을 갖는 경우가 많다. 하지만 개인적인 경험으로는 관리자 클래스를 이용하는 것이 가성비가 더 좋다. 물론 프레임워크의 크기가 거대하고 객체들의 종류와 종속성이 복잡하다면 좀 다른 방법을 써야겠지만, 유니티를 이용하는 정도 사이즈의 게임들은 매니저 클래스를 이용하는 정도로 충분하다. 만약 충분하지 않더라도 중간에 리펙토링을 하면 되고, 초반부터 오버 아키텍쳐링을 하는것이 더 비효율적이라 본다. 빠르고 직관적이게 개발을 하기 위해 이런 트리 형태의 참조, 역참조 관계를 만들어 두고 추후에 차근차근 리펙토링을 하면서 종속성을 제거해 나가는 것이 효과적이다.
    + 혹시라도 이보다 더 견고한 구조를 원한다면 언리얼의 GameInstance-Subsystem 구조를 참고해도 좋다.
    + 만약 이걸로도 만족이 안된다면 Dependency Injection 프레임워크(Zenject, VContainer)를 이용해보자.
     

    Scene 전환에 걸쳐서도 GameObject를 갖으며 존재하는 단 하나의 객체를 정의하여, Scene 전환에 걸쳐서도 유지되어야 하는 데이터들을 관리하도록 한다.

    위에서 언급한 GameManager의 경우 Scene의 루트 오브젝트로서 존재한다. 즉, Scene이 변경되면 파괴되는 객체이다. 하지만 게임개발을 진행하다 보면 Scene의 변경과 상관없이 관리되어야 하는 객체들과 데이터들이 존재한다. 예를 들면 서버로부터 받은 계정 정보를 저장한다거나, 게임의 Scene 전환 후에도 이전 데이터를 참조해야하는 경우 등이 있을 것이다. 이를 위해 MonoBehaviour를 포함하는 싱글턴 오브젝트를 정의하도록 한다. 추가적으로 관리되어야 하는 객체들이 있다면 이 객체의 하위로 보내면 되므로 싱글턴 객체는 하나 이상은 필요 없다. 
     

    public class GameInstance : Singleton<GameInstance>
    {
    	private LogGUI _logGUI;
    	private DebugStatGUI _debugStatGUI;
    
    	private ScriptableObjects.GamePrefabs _gamePrefabs;
    
    	private HttpManager _httpManager;
        
        // ..
    }
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    public abstract class Singleton<T> : MonoBehaviour where T : Component
    {
    	private static T instance;
    
    	public static T Instance
    	{
    		get
    		{
    			if (instance == null)
    			{
    				instance = FindObjectOfType<T>();
    				if (instance == null)
    				{
    					GameObject obj = new GameObject();
    					obj.name = typeof(T).Name;
    					instance = obj.AddComponent<T>();
    				}
    			}
    			return instance;
    		}
    	}
    
    	protected virtual void Awake()
    	{
    		if (instance == null)
    		{
    			instance = this as T;
    			DontDestroyOnLoad(gameObject);
    		}
    		else
    		{
    			Destroy(gameObject);
    		}
    	}
    }

     
    싱글턴 객체에 대해서도 부정적인 의견이 많다. 하지만 제대로 관리를 해줄 수 있다면 싱글턴 객체를 이용함으로써 얻는 이점이 더 많다. 싱글턴의 단점을 알아보자.
     
    1. 전역 변수로서의 문제


    전역 변수가 갖는 문제는 자명하다. 변수를 어디에서나 접근할 수 있기 때문에 모든 영역에 걸쳐서 코드가 변경될 수 있는 가능성이 생긴다. 그러므로 변수에 이상한 값이 들어가 있을 때 이 변수를 사용하는 모든 부분들을 의심해봐야 한다. 하지만 이 문제는 전역변수의 값이 수정 불가능하다면 애초에 발생하지 않을 문제이다. 싱글턴 객체 내부의 리소스들은 마음껏 Read 가능하도록 하되, Write에 대해서는 철저하게 관리하도록 설계한다면 이 문제는 해결될 수 있다. 특히 GamePrefabs와 같은 객체는 게임 내 대부분의 리소스를 참조할 수 있는 통로인데, 어차피 이 객체의 프로퍼티는 에디터에서만 변경되면 충분하기 때문에 모든 하위 객체를 포함하여 property로 set을 막아두도록 한다. 
    하지만, HttpManager와 같이 내부 변수들이 변하는 객체들도 분명 존재할 것이다. 이런 객체들의 값 변경은 최대한 이벤트를 이용해 내부적으로 처리하도록 하고, 외부에서 값을 변경시키는 상황은 철저하게 관리해야 할 것이다.
     

    2. 코드 결합도(Coupling) 문제

    모든 기능을 싱글턴 객체로 만들고 사방에서 싱글턴 객체를 호출하는 형태로 코드를 작성하면 프레임워크의 규모가 커질 때 관리가 불가능해질 것이다. 현재 코드로 이 상황을 완전히 피할 수는 없다. 하지만 최소화시킬 방법은 있는데, 싱글턴 객체를 딱 하나만 정의하고 그 외의 모든 싱글턴으로서 이용하고 싶은 객체들을 하위 객체로 넣는 것이다. 이렇게 설계하면 최소 싱글턴의 상호참조로 인한 커플링은 막을 수 있다. 그리고 개인적으로는 이 정도 구조로도 웬만한 게임을 개발하기에는 충분하다.

    이 논쟁에 대해서도, 위의 관리자 객체를 이용하는 상황과 마찬가지로 더 일반화된 구조를 원한다면 언리얼 엔진의 GameInstance와 SubSystem이나 Dependency Injection 프레임워크를 이용하여 해결할 수 있다. 결국 이런 설계적인 논쟁에는 끝이 없다. 프로젝트 사이즈에 맞게 최소의 자원으로 최대의 효율을 낼 수 있는 적절한 타협점을 찾는것이 중요하다. (항상 프로젝트의 완성이 최우선 목표가 되어야한다.)
     

    3. 단일 책임 원칙 위배

    하나의 객체는 하나의 책임을 담당하며, 그 책임을 완전히 캡슐화해야한다는 원칙이다. 현재 단 하나의 싱글턴 객체를 정의했기 때문에, 이 객체가 많은 책임을 갖게 될 수 있다. 그러므로 새로운 책임이 생길 때마다 새로운 하위 객체를 갖는 형태로 구성하도록 한다. 위에 예제로 보면 GamePrefabs 객체를 통해 리소스 참조의 책임을 넘겼고, HttpManager 객체를 통해 REST API 네트워킹에 대한 책임을 넘겼다. 이처럼 싱글턴을 이용하더라도 단일 책임 원칙을 위배하지 않도록 설계할 방법은 있다.
     

    4. 초기화와 소멸 타이밍을 잡기 어려운 문제

    서버개발자들이 싱글턴 객체를 싫어하는 이유 중 하나는 멀티스레드 환경에서 공유자원의 영역이 커진다는 것이고, 다른 하나는 초기화와 소멸 타이밍을 잡기 어렵다는 것이다. 먼저 유니티의 게임 루프는 싱글 스레드 기반이기 때문에 첫번째 문제는 배제할 수 있다. 그렇다면 초기화와 소멸 타이밍은 어떻게 잡을 수 있을까? 유니티 엔진이 제공하는 기능들을 활용하면 된다. 다음 함수를 GameInstance 객체에 넣어서 초기화 타이밍을 잡기 위해 활용한다.

    // 어떤 Scene이 시작되기 전 호출
    [RuntimeInitializeOnLoadMethod]
    static void OnBeforeSceneLoadRuntimeMethod()
    {
    	// 초기화가 필요한 경우 여기서 싱글턴을 초기화한다.
        // GameInstance.Instance.Init();
    }

     
    다음으로 소멸 타이밍은 어떻게 잡아야할까? 결론부터 말하자면 이 부분은 아직 완벽하게 해결할 방법을 찾지 못했다. 사용자가 프로그램을 언제 종료시킬지 모르기 때문에 명시적인 타이밍을 잡기 어렵다. 유니티에서는 OnApplicationQuit 이라는 이벤트가 있는데, 딱 봤을 때 앱이 종료될 때 호출될 것 같지만 공식 문서에서는 항상 호출됨을 보장하지 않는다고 한다. 다행히도 대체할 이벤트가 있다. OnApplicationPause을 이용하면 된다. OnApplicationPause (bool) 이벤트의 경우 모바일에서 창이 전환될 때 바로 호출되기 때문에 종료되기 전에 반드시 호출됨을 보장할 수 있다. 다만 창만 내렸다가 다시 돌아오는 경우가 있기 때문에 bool 인자를 통해 창이 내려가면서 발생된 이벤트인지 창이 올라오면서 발생한 이벤트인지 구분해야한다. 그리고 소멸 메커니즘을 만들었다면 회복 메커니즘도 만들어줘야할 것이다. 다만 모든 상황에 대한 회복 메커니즘을 만드는 작업과, 창을 내렸다가 올릴때마다 다시 로딩하는것이 사용자에게 좋은 경험일지는 모르므로 완벽한 해결방법이라고 하기는 어려울 수 있다.
     

    private void OnApplicationPause(bool pause)
    {
    	Debug.LogError($"GameInstance OnApplicationPause {pause}");
    }

     
    다음처럼 모바일 환경에서 창을 내렸다가 돌아왔을 때 이벤트를 로그로 남긴 것을 확인할 수 있다.

     
    참고 링크
    https://answers.unity.com/questions/824790/help-with-onapplicationquit-android.html
     
     
     
     
     

    에디터 타임에 수정되며, 게임 타임에는 변하지 않을 데이터들을 ScriptableObject로 정의한다.

    게임을 개발하다보면 굉장히 다양한 형태의 데이터들을 정의하게 된다. 그 중에서 에디터 타임에는 변경될 수 있으며 게임 타임에는 변하지 않는 데이터는 어떤 데이터들일까? 스타크래프트를 예로 들어보자. 울트라리스크는 400의 체력을 갖는다. 여기서 울트라리스크가 공격을 받는다면 체력이 줄어들게 된다. 이 줄어드는 체력은 인스턴싱 된 객체의 변수 하나로 표현할 수 있을 것이다. 하지만 최초의 400이라는 정보는 어디에 저장되어 있었을까? 보통 이런 데이터들은 데이터 테이블이라는 리소스에 저장되고, 기획자들에 의해 관리된다. 유니티에서는 이러한 데이터를 다루기 위해 ScriptableObject 객체를 지원한다. 이 객체로 정의된 데이터는 에디터 위에서 인스턴싱될 수 있으며, 에디터 타임에 값을 수정할 수 있다. 그리고 Prefab과 같은 형태로 GameObject를 통해 참조하여 사용될 수 있다.
    그러므로 아이템 데이터, 캐릭터 데이터, 적들의 데이터 등등 게임을 기획하며 만들어지는 대부분의 데이터들은 ScriptableObject로 정의하도록 한다. 그리고 이 변수들은 에디터 수준에서만 변경 가능하도록 설계하여 기획자들이 마음껏 수정해볼 수 있도록 하고, 개발자들은 게임 유지보수 과정에서 신경쓰지 않아도 되도록 한다. 또한 네임스페이스로 묶어두어 네임충돌을 피하도록 하고, 자동완성의 이점을 얻도록 한다.
     

    namespace ScriptableObjects
    {
    	[Serializable]
    	[CreateAssetMenu(fileName = "Data", menuName = "ScriptableObjects/Artifact", order = 1)]
    	public class Artifact : ScriptableObject
    	{
    		[SerializeField]
    		private Sprite _thumbnail;
    
    		[SerializeField]
    		private string _name;
    
    		[SerializeField]
    		private StatModifierGroupData _statModifierGroup;
    
    		public Sprite Thumbnail
    		{
    			get { return _thumbnail; }
    		}
    
    		public string Name
    		{
    			get { return _name; }
    		}
    
    		public StatModifierGroupData StatModifierGroup
    		{
    			get { return _statModifierGroup; }
    		}
    
    	}
    
    }

    아무때나 편하게 데이터에 접근할 수 있도록 데이터 덩어리 객체를 정의한다.

    급하게 게임 개발을 진행하다 보면 객체마다 데이터를 박아가며 개발을 진행하게 되는데, 이 데이터들을 변경하면 또 다시 에디터에서 핫 리로드를 하게 되는 불편함이 있고, 데이터가 흩어져서 관리하기 어려워진다는 불편함도 있다. 또한 ScriptableObject나 GameObject와 같은 객체를 참조하고자 하는 경우 모두 [SerializeField]로 박아서 사용하기 부담된다는 점도 있다. 그러므로 무지성으로 사용하고싶은 모든 데이터들을 담아둘 덩어리 객체를 정의하여, 자주 사용되던 테스팅으로 사용되던 필요하다면 박아넣고 사용할 수 있는 공간을 확보한다. 이 객체 또한 ScriptableObject로 정의하고 내부적으로는 region을 이용하여 데이터들을 구분하도록 한다.

    namespace ScriptableObjects
    {
        [System.Serializable]
        [CreateAssetMenu(fileName = "Data", menuName = "ScriptableObjects/GamePrefabs", order = 1)]
        public class GamePrefabs : ScriptableObject
        {
    		#region Using
    		public ScriptableObjects.StatEffectGroup statEffectGroup;
    
    		public ScriptableObjects.Stage initStage;
    
    		public ScriptableObjects.StartCardDeck startCardDeck;
    
    		public ScriptableObjects.ArtifactGroup startArtifacts;
    
    		public ScriptableObjects.SpriteGroup spriteGroup;
    
    		public List<ScriptableObjects.Card> allSpawnableCards;
    
    		public ScriptableObjects.ArtifactGroup allSpawnableArtifacts;
    
    		public int buildTryCount;
    
    		public int startLife;
    		// ..
    		#endregion
    
    
    		#region Test
    		public int testValue;
    		#endregion
    
    	}
    
    }

     
    객체가 정의되었다면 어디서든 이 객체에 접근할 수 있도록 이전에 정의한 싱글턴 오브젝트가 이 객체를 참조하도록 한다. 이제 필요할때마다 GamePrefabs에 프로퍼티로 데이터를 박아넣을 수 있고, 에디터에서 수정가능하며 GameInstance로 어디서든 참조할 수 있을 것이다.

    public class GameInstance : Singleton<GameInstance>
    {
    	// ..
    	private ScriptableObjects.GamePrefabs _gamePrefabs;
    	// ..
        
    	public ScriptableObjects.GamePrefabs GamePrefabs 
    	{ 
    		get { return _gamePrefabs; } 
    	}
    	// ..
    }

     리소스들을 종류별로 그룹지어 묶어둔 데이터 덩어리들을 정의한다.

    GamePrefabs와 마찬가지로 게임 내에서 자주 참조되는 리소스들은 그룹지어 묶어두도록 한다. 예를 들면 SpriteGroup이라는 객체를 통해 자주 사용되는 sprite들을 모아둔다거나, ParticleEffectGroup을 통해 자주 사용되는 파티클들을 모아두는 것이다. 게임개발을 하다보면 이런 리소스를 찾아다니며 낭비하는 시간이 적지 않다. 또한 코드 상에서 이 리소스를 참조해오기 위해 낭비되는 시간까지 생각한다면, 그냥 객체 하나에 다 넣어놓고 이용하는 것이 훨씬 편하다. 물론 추후에 최적화 단계에서는 정리를 해야겠지만, 빠르게 개발해나가는 단계에서는 이 방법이 효과적이다. 
    이 객체들도 GamePrefabs 객체의 하위 프로퍼티로 넣어서 어디서든 참조될 수 있도록 한다.
     

    namespace ScriptableObjects
    {
        [Serializable]
        [CreateAssetMenu(fileName = "Data", menuName = "ScriptableObjects/CommonGroup/ParticleGroup", order = 1)]
        public class ParticleGroup : ScriptableObject
        {
            public List<GameObject> particles;
        }
    }

    BaseMonoBehaviour 객체를 통해 MonoBehaviour를 래핑한다.

    유니티에서는 gameObject에 스크립팅을 위해 MonoBehaviour 객체를 이용한다. 하지만 MonoBehaviour는 부족한 점이 많다. 그래서 몇가지 기능들을 확장해서 사용할 필요가 있다.
    1. MonoBehaviour의 리플렉션 이벤트들은(Awake, Start, Update, OnDestroy ..) 굉장히 느리다. 그러므로 자체적인 이벤트 호출루틴을 구축할 필요가 있다.
     
    참고 링크
    https://blog.theknightsofunity.com/monobehavior-calls-optimization/
     
     
    2. 모든 MonoBehaviour 객체에 OnDestroy 시에 레퍼런스를 놓아주는 코드를 넣어주어야 한다. 이 내용은 아래에 자세히 설명되어 있다.
     
     
     
     
     

    OnDestroy 시에는 반드시 모든 레퍼런스를 놓아주도록 한다.

    유니티에는 가비지 컬렉터가 있으니 메모리는 알아서 관리해줄 것이다.. 라는 착각은 버리자. 가비지 컬렉터를 돌리더라도 계속 해제되지 않고 떠다니는 메모리들이 존재한다. 왜 그럴까? 이는 Ghost Reference가 남아있기 때문인데 구체적인 예시를 보자.

    public class SomeObject : MonoBehaviour
    {
        public Sprite _sprite;
    }

     
    SomeObject는 어떤 sprite를 참조중이다. 대부분의 유니티 개발자들은 이때 이 SomeObject가 파괴된다면 sprite에 대한 참조도 사라질 것이라 예상한다. 하지만 그렇지 않다. 파괴되더라도 c++객체인 gameObject는 바로 제거되지만 C# 객체인 MonoBehaviour는 GC되기 전까지는 메모리에 남아있다. 그러므로 이 객체가 참조하고 있는 Sprite에도 레퍼런스가 남아있게 된다. 이처럼 파괴되더라도 참조가 남기 때문에 GC의 레퍼런스 그래프가 비대해지고 이를 풀어내는 부담이 커지게 된다. 이 부담은 게임 틱이 비정상적으로 튀는 문제로 귀결될 것이다. 그러므로 OnDestroy에서 모든 참조 변수들을 null로 세팅하여 GC가 쉽게 메모리를 해제할 수 있도록 한다.
     

    public class SomeObject : MonoBehaviour
    {
        public Sprite _sprite;
    	private void OnDestroy()
    	{
    		_sprite = null;
    	}
    }

     
    그러나 멤버 객체 변수를 사용할 때마다 매번 OnDestroy 시에 null로 세팅해주는 일은 귀찮은 일이다. 이런 경우 Reflection을 이용하여 쉽게 이 작업을 자동화시킬 수 있다.

    public class BaseMonoBehaviour : MonoBehaviour
    {
        void OnDestroy()
        {
    		FieldInfo[] info = GetType().GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
    		foreach (FieldInfo field in info)
            {
                Type fieldType = field.FieldType;
     
                if (typeof(IList).IsAssignableFrom(fieldType))
                {
                    IList list = field.GetValue(this) as IList;
                    if (list != null)
                    {
                        list.Clear();
                    }
                }
     
                if (typeof(IDictionary).IsAssignableFrom(fieldType))
                {
                    IDictionary dictionary = field.GetValue(this) as IDictionary;
                    if (dictionary != null)
                    {
                        dictionary.Clear();
                    }
                }
     
                if (!fieldType.IsPrimitive)
                {
                    field.SetValue(this, null);
                }
            }
        }
    }

    이제 모든 MonoBehaviour 객체는 이 클래스를 상속받도록 한다.
     
     
     
    참고 문서
    https://answers.unity.com/questions/377510/does-destroy-remove-all-instances.htmlDoes Destroy remove all instances? – Unity Answers answers.unity.com

     
    https://medium.com/@lynxelia/unity-memory-management-how-to-manage-memory-and-reduce-the-time-it-takes-to-enter-play-mode-fd07b43c1daaUnity Memory Management: How to manage memory and reduce the time it takes to enter play modeA lot of people have been running into the issue where the editor will take a really long time to load the scene after clicking “Play”…medium.com

    Unity에서 Reflection을 이용할 때에는 주의해서 이용하도록 한다.

    Unity 엔진의 C#에서도 동일하게 Reflection을 사용할 수 있다. 하지만, iOS의 경우에는 자체 저수준 가상 머신인 LLVM 환경 에서 제한적으로만 Reflection을 허용한다. 이 환경에서는 Mono Runtime에 맞도록 미리 컴파일하는 AOT(Ahead of Time) 컴파일 방식을 채용하고 있어서 Reflection 기능 중에 매우 큰 강점인 동적인 코드 생성을 할 수 없다. 다행히도 AOT를 이용하더라도 Field 타입과 Method 리스트를 받아오거나, Method를 실행시키는 등의 기본적인 Reflection 기능들은 모두 동작한다. 그러므로 Unity에서 Reflection을 사용해야하는 상황이 생긴다면 되도록이면 이 점을 숙지하고 사용하는 것이 좋을 것이다. 위에서 언급한 OnDestroy 상황처럼 사용해도 되고, 디버깅이나 테스팅을 위해 모든 객체를 래핑하는 방향으로 사용되기도 한다.
     
     
     

    Unity의 GameObject의 OnDestroy 순서는 숙지해두도록 한다.

    객체의 OnDestroy 순서를 알아야 적절한 순서로 객체를 정리할 수 있다. 이미 파괴된 객체의 레퍼런스를 건드리면 문제가 생기기 때문이다. 그러므로 다음과 같은 상황들에 대한 파괴 순서를 알아둘 필요가 있다.
     
    1. Parent와 같은 Parent 를 둔 GameObject들 사이에서의 파괴 순서
    예를들어 A라는 GameObject의 자식으로 B, C 가 순서대로 생성되었다고 가정해보자. OnDestroy 이벤트는 A, C, B 순서로 일어난다. 
     
    2. 같은 GameObject에 여러 컴포넌트가 붙어있을 때, 이 컴포넌트들 사이의 파괴 순서
    같은 GameObject 사이에 여러 컴포넌트에 대해서는 딱히 명시된 순서가 없다.
     
     

    PlayerPrefs를 이용하여 Application 실행/종료 상황에서도 유지되어야 하는 데이터들을 관리하도록 한다.

    PlayerPrefs는 데이터를 유니티 고유 캐시 파일에 저장한다. 물론 캐시가 삭제된다면 이 데이터들도 제거되기 때문에 이 공간에 어떤 데이터를 저장시킬 것인지는 많이 고민해봐야할 것이다. 보통은 게임 옵션, 게임 저장, 로그인 캐시(성공 시)와 같은 클라리언트 레벨에서만 저장되어도 크게 무리없는 데이터들을 저장한다. 파일에 저장되는만큼 저장 키가 겹치지지 않도록 주의하여야 하고, 따로 관련 코드들을 모아두는 공간이 있다면 좀 더 효과적이다.

    namespace CacheData
    {
    	public class GoogleSignInData
    	{
    		private static class Meta
    		{
    			public const string AuthCode = "GoogleLogin_AuthCode";
    			public const string Email = "GoogleLogin_Email";
    			public const string IdToken = "GoogleLogin_IdToken";
    			public const string DisplayName = "GoogleLogin_DisplayName";
    			public const string GivenName = "GoogleLogin_GivenName";
    			public const string FamilyName = "GoogleLogin_FamilyName";
    			public const string UserId = "GoogleLogin_UserId";
    			public const string SignInStatusCode = "GoogleLogin_SignInStatusCode";
    		}
    
    		private string _authCode;
    		private string _email;
    		private string _idToken;
    		private string _displayName;
    		private string _givenName;
    		private string _familyName;
    		private string _userId;
    
    		public string AuthCode
    		{
    			get { return _authCode; }
    		}
    		public string Email
    		{
    			get { return _email; }
    		}
    
    		public string IdToken
    		{
    			get { return _idToken; }
    		}
    
    		public string DisplayName
    		{
    			get { return _displayName; }
    		}
    
    		public string GivenName
    		{
    			get { return _givenName; }
    		}
    
    		public string FamilyName
    		{
    			get { return _familyName; }
    		}
    
    		public string UserId
    		{
    			get { return _userId; }
    		}
    
    		public void Set(Google.GoogleSignInUser signIn)
    		{
    			Set(signIn.AuthCode, signIn.Email, signIn.IdToken, signIn.DisplayName, signIn.GivenName, signIn.FamilyName, signIn.UserId);
    		}
    
    		public void Set(string authCode, string email, string idToken, string displayName, string givenName, string familyName, string userId)
    		{
    			_authCode = authCode;
    			_email = email;
    			_idToken = idToken;
    			_displayName = displayName;
    			_givenName = givenName;
    			_familyName = familyName;
    			_userId = userId;
    		}
    
    		public static void Save(GoogleSignInData googleSignIn)
    		{
    			PlayerPrefs.SetString(Meta.AuthCode, googleSignIn.AuthCode);
    			PlayerPrefs.SetString(Meta.Email, googleSignIn.Email);
    			PlayerPrefs.SetString(Meta.IdToken, googleSignIn.IdToken);
    			PlayerPrefs.SetString(Meta.DisplayName, googleSignIn.DisplayName);
    			PlayerPrefs.SetString(Meta.GivenName, googleSignIn.GivenName);
    			PlayerPrefs.SetString(Meta.FamilyName, googleSignIn.FamilyName);
    			PlayerPrefs.SetString(Meta.UserId, googleSignIn.UserId);
    			PlayerPrefs.Save();
    		}
    
    		public static GoogleSignInData Load()
    		{
    			GoogleSignInData googleSignIn = null;
    
    			if (PlayerPrefs.HasKey(Meta.UserId))
    			{
    				googleSignIn = new GoogleSignInData();
    
    				string authCode = PlayerPrefs.GetString(Meta.AuthCode, "");
    				string email = PlayerPrefs.GetString(Meta.Email, "");
    				string idToken = PlayerPrefs.GetString(Meta.IdToken, "");
    				string displayName = PlayerPrefs.GetString(Meta.DisplayName, "");
    				string givenName = PlayerPrefs.GetString(Meta.GivenName, "");
    				string familyName = PlayerPrefs.GetString(Meta.FamilyName, "");
    				string userId = PlayerPrefs.GetString(Meta.UserId, "");
    
    				googleSignIn.Set(authCode, email, idToken, displayName, givenName, familyName, userId);
    			}
    
    			return googleSignIn;
    		}
    
    		public static void Remove()
    		{
    			PlayerPrefs.DeleteKey(Meta.UserId);
    		}
    	}
    }

    유니티에서 사용되지 않는 플러그인과 에셋들을 정리하여 스크립트 리로딩 시간을 단축시킨다.

    유니티로 개발을 하다 보면 어느 순간부터 스크립트 리로딩 시간이 30초가 넘어가는 경험을 하게 된다. 작성한 스크립트나 추가한 에셋들이 늘어나고, 사용하지 않는 유니티 플러그인들이 계속 쌓여나가며 리로딩해야하는 대상이 점점 많아지기 때문에 그렇다. 이 시간을 단축시켜야 한다.
     
    1. 어디서 스크립트 리로딩 시간이 많이 소요되는지 파악한다.
    일단 스크립트 리로딩의 병목이 무엇인지 파악해야한다.
    이를 위해 Edit -> Preferences -> Diagnostics -> EnableDomainReloadTimings를 True로 세팅한다. 이렇게 하면 유니티의 리로드 관련한 에디터 로그를 남길 수 있다. 에디터 로그는 C:/User/AppData/Local/Unity/Editor/Editor.log에 남는다.
    로그에서 다음과 같은 부분을 찾는다.

    리로딩 시간이 총 2.7초 정도 걸렸다. (원래 20초 가량 나왔었는데 단축시킨 결과이다.) 로그를 내려보면 특히나 시간을 많이 잡아먹는 모듈들을 기억해두자.
     
    2. 유니티에서 제공하는 플러그인중 필요하지 않는 플러그인들을 제거한다.
    다음으로 사용하지 않는 모듈, 플러그인들을 제거한다. 
    Unity -> Window -> Package Manager 에서 제거하면 된다.
     
    3. 수정될 일이 거의 없는 에셋들과 스크립트들을 Plugins 폴더로 옮긴다.
    플러그인이 아니더라도, 직접 작성한 스크립트 코드가 늘어난다면 리로딩 시간이 증가할 수 있다. 이 시간을 단축시키기 위해서 수정될 일이 거의 없는 라이브러리 코드들과 구매한 에셋들을 Plugins 폴더로 옮기도록 한다. 이렇게 되면 매번 리로딩되는 대상에서 제외되기 때문에 리로딩 시간을 상당 부분 줄일 수 있다. 
     
    유니티에서 사용되는 로그파일들이 어디에 생성되는지 확인해둔다.
    1. 유니티 콘솔 로그, 에디터 로그
    가장 기본적인 에디터 콘솔 로그는 유니티 에디터 상에서 콘솔 패널을 통해 확인할 수 있다. 실제 파일의 위치는 다음 경로에 남는다.

    C:/User/username/AppData/Local/Unity/Editor/Editor.log

    해당 파일을 통해서 콘솔 패널에 표시되는 로그 뿐만 아니라 에디터 자체적으로 남기는 로그까지 좀 더 디테일하게 로그를 볼 수 있다.
     
    2. 유니티 standalone build 로그
    다음으로 standalone으로 빌드된 실행파일의 로그가 남는 위치도 확인해볼 필요가 있다. 빌드된 앱의 로그는 다음 경로에 남는다.

    C:/Users/username/AppData/LocalLow/companyname/projectname/Player.log

    유니티 로그관련

    유니티에서 모든 로그가 출력되지 않도록 세팅하고싶을 수 있다. 그런 경우 다음 코드를 넣어주면 된다.

    Debug.unityLogger.logEnabled = false;

    Event(Delegate)

    이벤트는 대부분의 언어에서 채용되고 있는 효과적인 문법이다.
    이벤트를 이용하면 다음과 같은 상황을 쉽게 처리할 수 있다.
    1. 마우스를 클릭 시, 현재까지의 정보를 저장한 후, 이펙트가 발생하고 화면이 전환된다.
    2. 내 캐릭터가 벽에 충돌했을 때, 사운드가 트리거된 후 퀘스트 팝업이 뜬다.
     
    일반적으로 좋은 설계는 객체를 하나의 업무만 담당하도록 잘 분리하는데서부터 시작된다. 그렇게 해야 여러 분리된 조각들로 더 큰 조각을 조립할 수 있기 때문이다(코드의 재활용성이 극대화된다). 하지만 이처럼 잘 분리된 객체들로 위와 같은 상황을 처리해야한다면 어떻게 될까? 이벤트를 발생시키는 객체와, 발생된 타이밍에 데이터가 변화하거나 특정 루틴을 실행해야하는 객체가 분리되어 있는 경우에 그 타이밍과 데이터만 전달시킬 수는 없는 것일까? 과거부터 이 작업을 하기 위해 여러가지 해결방법으로 제시된 설계패턴들이 있지만, 현대에서 가장 효과적으로 이 상황을 해결하는 방법은 이벤트를 이용하는 것이다.
     
    캐릭터가 벽에 충돌했는지에 대한 작업을 담당하는 어떤 객체를 정의한다고 생각해보자. 

    class CollisionSystem
    {
    	List<Character> characters;
    
        void CheckWallCollisions()
        {
            // algorithm
            foreach(Character character in characters)
            {
            	if(CheckWallCollision(character, wall))
                {
                	// ..
                }
            }
        }
    }

     
    이 시스템은 등록된 캐릭터에 대해 벽과의 충돌여부를 계산한다. 하지만, 이용하는 측면에서는 그런 구체적인 알고리즘은 관심이 없다. 단지, 어느 타이밍에 벽과 충돌했고, 그 순간의 충돌정보들에 대해서만 관심이 있을 뿐이다. 그러므로 CollisionSystem은 그 정보를 제공해줄 의무가 있다.
     

    class CollisionSystem
    {
    	public static event Action<Character, Wall> onCharacterCollideToWall;
    	List<Character> characters;
    
        void CheckWallCollisions()
        {
            // algorithm
            foreach(Character character in characters)
            {
            	if(CheckWallCollision(character, wall))
                {
                	if(onCharacterCollideToWall != null)
                    {
                    	onCharacterCollideToWall.Invoke(character, wall)
                    }
                }
            }
        }
    }

     
    위처럼 onCharacterCollideWall이라는 이벤트를 제공해줌으로써 이제 분리된 외부의 객체들이 충돌하는 순간에 원하는 작업을 수행할 수 있게 된다.
     

    class GameSystem
    {
    
    	void Awake()
        {
        	CollisionSystem.onCharacterCollideWall += OnCharacterCollideWall;
        }
        
        void OnDestroy()
        {
        	CollisionSystem.onCharacterCollideWall -= OnCharacterCollideWall;
        }
    
    
    	 void OnCharacterCollideWall(Character character, Wall wall)
         {
         	// ..
         }
    }

    Interface

    d
     
     

    System

    System이라는 네이밍은 꽤나 다양하게 이용되지만 개인적으로는 어떤 특정 컨셉에 대해 매니징하는 서브매니저 객체정도로 정의하고 있다. 게임을 예로 들면 충돌에 대한 처리는 CollisionSystem이 담당하고, 게임 플로우에 대한 처리는 GameSystem이 담당하도록 설계할 수 있을 것이다. 이처럼 처리하고싶은 각 상황에 맞는 System 객체들을 정의하고 모든 System 객체를 IGameManager가 소유하도록 하여 서로 상호이용할 수 있다록 설계한다.
     
    다음과 같은 게임의 카드 처리를 담당하는 시스템을 상상해보자.
    https://www.youtube.com/watch?v=3hsB-07IX4A&t=1288s 
     

    class CardSystem
    {
    // member values
    
    // getter & setter
    
    // construct & destruct
    
    // event handlers
    
    // data processing
    
    // forward functions
    
    // events
    
    // simulation
    
    }

     
    CardSystem은 카드 데이터와 관련된 온갖 작업들을 매니징해야한다. 각 작업들을 함수로 정의할텐데 이 함수들을 컨셉에 맞게 분류하는것이 중요하다.
     
    먼저 이 시스템이 처리할 카드 데이터를 정의해야한다.

    class Card : ICard
    {}

     
    CardSystem이 이 카드 데이터를 활용하여 처리하는 작업들은 다음과 같이 분류될 수 있다.
    1. member values
    대부분의 카드 관련한 데이터는 Card 객체를 통해 저장되어야할 것이다. 하지만 시스템적으로만 사용될 변수들은 Card 객체에 둘 것이 아니라 System 객체에 저장해야한다. 다른 시스템에 대한 참조나 임시 변수같은 것들이 있을 것이다.

    class CardSystem
    {
    	private IGameManager _gameManager;
    }

     
    2. getter & setter
    멤버 변수에 대한 getter setter들을 지원해야한다.
     
     
    3. construct & destruct
    이 시스템에 대한 초기화와 파괴루틴은 명확하게 정의해주어야한다. 초기화 과정에서는 다른 이벤트에 대한 구독과 멤버 변수에 대한 세팅이 포함될 것이고, 파괴에서는 구독 해지와 멤버 변수 정리작업이 필요하다.

    class CardSystem
    {
    //..
    	public CardSystem(IGameManager gameManager)
        {
        	_gameManager = gameManager;
        }
    
    	void OnAwake()
        {
        	GameSystem.onBeginGame += OnBeginGame;
        }
        
        void OnDestroy()
        {
            GameSystem.onBeginGame -= OnBeginGame;
        }
    }

     
     
    4. event handlers
    이벤트 핸들러는 다른 시스템의 이벤트에 구독된 함수들이 포함된다. 예를들어 GameSystem에서 게임 시작을 알리는 onBeginGame 이벤트를 제공할 때, CardSystem에서는 OnBeginGame 함수를 정의하여 해당 이벤트에 구독시켜야할 것이다. OnBeginGame 내부 로직은 게임 시작 시 CardSystem에서 카드에 대한 처리 작업들이 포함될 것이다. (카드 생성, 카드 드로우 등등)

    class CardSystem
    {
    //..    
        void OnBeginGame()
        {
        	CreateCards();
        	DrawCards();
            // ..
        }
    }

     
     
    5. data processing 
    데이터 프로세싱 함수는 이 시스템에서 다룰 데이터의 변경과 관련된 로직만 포함한 함수들을 의미한다. 예를 들어 아래 코드와 같은 함수들이 있을 것이다. 이 함수들은 데이터의 변환에만 관여하도록 설계해야한다. 이 데이터 변환 작업에는 모든 복잡한 알고리즘들이 포함된다. 그 후 외부에서 유틸리티 함수로 사용하기 편하게 하기 위해 static 함수로 설계하는 것이 좋다. 또한 관리하기 편하도록 내부 static class로 묶어준다.
    (simulation 코드와 데이터 변환 코드를 철저하게 분리해야 좋은 설계가 가능하다. 이에 대해서는 이후에 설명한다.) 

    public class CardSystem
    {
    // ..
        public static class DataProcessing
        {
            public static ICard FindCard(IDeck deck, long id){}
    
            public static ICard FindCard(IDeck deck, string name){}
    
            public static void PlaceCard(ICard card, EnumTypes.PlaceTypes place){}
    
            public static void SetCardCost(ICard card, int cost){}
            
            public static ICard CreateCard(string name, int cost, EnumTypes.PlaceTypes place){}
        }
    }

     
    6. forward functions
    이미 정의된 데이터 변환 함수들을 활용하여 인터페이스에 대한 간단한 변경만 주어 정의되는 함수들을 의미한다. 시스템 객체에서 제공할 수 있는 변수들은 굳이 파라메터로 넘겨주지 않아도 된다.

    public class CardSystem
    {
        public ICard FindCard(long id)
        {
            return DataProcessing.FindCard(GetDeck(), id);
        }
    
        public ICard FindCard(string name)
        {
            return DataProcessing.FindCard(GetDeck(), name);
        }
    }

     
    7. events
    CardSystem도 자신의 이벤트를 외부에 제공할 필요가 있다. 카드를 섞는 경우, 카드를 드로우하는 경우, 카드를 생성하는 경우 등등 다양한 시뮬레이션 상황에 대해 외부 객체들은 관여하고자 할 것이다. 이러한 상황에 대한 이벤트들을 정의해야한다. 마찬가지로 관리하기 편하도록 내부 static class로 묶어준다.

    public class CardSystem
    {
    // ..
    
    	public static class Events
        {
            public static event Action<List<ICard>> onShuffleCards;
    
            public static void OnShuffleCards(List<ICard> cards)
            {
                if(onShuffleCards != null)
                {
                    onShuffleCards.Invoke(cards);
                }
            }
    
            public static event Action<List<ICard>> onDrawCards;
    
            public static void OnDrawCards(List<ICard> cards)
            {
                if(onDrawCards != null)
                {
                    onDrawCards.Invoke(cards);
                }
            }
            //..
        }
        
    }

     
    8. simulation
    시뮬레이션 코드는 최종적으로 이 카드 시스템에서 하고자 하는 모든 작업들이 포함된다. 카드 생성, 카드 드로우, 카드 버리기 등등 위에서 정의한 모든 함수들을 활용하여 최종적인 결과물을 만들어내는 함수들이고, 코드를 가장 어렵게 만드는 주범이다. 시뮬레이션 코드가 깔끔해지려면 데이터 변환 코드들을 섬세하게 다 분리해내야한다. 그 후 변환 함수들과 이벤트를 섞어서 이 함수들을 정의하면 된다.

    class CardSystem
    {
        public void CreateCard(string name, int cost, EnumTypes.PlaceTypes place)
        {
        	// data 작업
        	ICard card = DataProcessing.CreateCard(name, cost, place);
            
            // 외부 시스템 활용
            TriggerSystem triggerSystem = _gameManager.GetSystem<TriggerSystem>();
            triggerSystem.AddTriggerable(card);
            
            TurnSystem turnSystem = _gameManager.GetSystem<TurnSystem>();
            turnSystem.AddTurnable(card);
            
            // 외부로 이벤트 전달
            Events.OnCreateCard(card);
        }
    
        public void DrawCard()
        {
        	// .. 
        }
    
        pubilc void ShuffleCard()
        {
    		// ..
        }
    }

    simulation 코드와 data processing 코드를 분리해야하는 상황에 대해 좀더 명확한 이해를 위해 CreateCard 예제에 주목하면 좋다. CreateCard는 단순히 Card라는 객체를 만드는 작업도 있겠지만, Card라는 객체가 데이터로서 생성될 뿐만 아니라 여러 시스템 객체에 등록되고 이벤트까지 전달시켜주어야하는 작업도 있다. 그 두 작업은 명확하게 분리되어야한다. 그렇기 때문에 개발 과정에서 이 함수는 시뮬레이션이 목표인지, 데이터 변환이 목표인지를 잘 구분해야한다.
     

    Dependency Resolve

    객체를 생성하고 각 객체들 간 참조 순서를 해결하기 위한 규칙을 정리할 필요가 있다. 예를 들어 다음과 같은 상황을 가정해보자. A 객체와 B 객체가 있다. 각 객체들은 매니저 클래스를 통해 인스턴싱된다. 인스턴싱 후 A 객체는 초기화를 위해 B 객체의 데이터가 필요하고, B 객체는 A 객체의 이벤트에 자신의 함수를 등록해야한다. 이 문제를 어떻게 해결하면 좋을까? 이를 위해 우리는 객체 초기화의 과정에서 인스턴싱과 초기화 두 가지를 분리하여 설계해야한다. 유니티에서 제공하는 Awake 함수는 인스턴싱을 위한 이벤트라고 볼 수 있다. 그러므로 먼저 Awake 단계에서 각 객체들이 사용할 하위 객체들을 모두 인스턴싱해둔다. 그 이후 초기화 함수(Init)를 따로 제공해야한다. Init 함수에서는 각 객체들 간의 상호참조를 위한 파라메터들을 공유하도록 하여 문제를 해결할 수 있다. 

     
    Dependency Injection

     

    상태 관리와 FSM

    게임 개발에 있어서 상태 관리는 피할 수 없는 중요한 개념이다. 상태 관리가 무엇인지 예시를 통해 알아보자.

    스타크래프트의 저글링을 생각해보자. 저글링은 움직일 수 있고, 공격할 수 있고 가만히 있을 수 있다. 그리고 체력이 0이 되면 죽는다. 이처럼 저글링이라는 객체는 여러 가지 상태를 갖을 수 있고, 각 상태에 따라 처리해야하는 로직이 달라질 수 있다. 공격중일때는 공격 애니메이션을 출력시키면서 동시에 공격 딜레이를 계산해야할 것이다. 움직일 때는 이동 애니메이션을 출력시키면서 동시에 이동 속도 계산과 이동 위치 계산이 이루어져야 할 것이다. 이와 같이 하나의 객체가 여러 상태로써 표현되어야 하는 경우 유한상태기계 (Finate State Machine) 라는 개념을 활용하면 유용하다.

    FSM은 요구사항이 간단한 경우 직접 개발하여 사용하기도 하지만, 개인적인 경험으론 추가적인 요구사항이 생기면서 가볍게 만들었던 FSM 코드를 버리고 처음부터 다시 만들어야 했었다. 현재는 C# FSM 오픈소스인 stateless를 이용하고 있다. 현대적인 설계철학을 잘 활용하는 동시에 다양한 요구사항을 충족시키면서 쓰기도 간편하다.

    https://github.com/dotnet-state-machine/stateless.gitGitHub – dotnet-state-machine/stateless: A simple library for creating state machines in C# codeA simple library for creating state machines in C# code – GitHub – dotnet-state-machine/stateless: A simple library for creating state machines in C# codegithub.com

    오픈소스

    개발 전 반드시 참고하는 오픈소스들이다.
     
     

    유니티의 수많은 유용한 오픈소스들을 정리해놓은 레포지토리

    https://github.com/michidk/Unity-Script-CollectionGitHub – michidk/Unity-Script-Collection: A maintained collection of useful & free unity scripts / library’s / plugins and extenA maintained collection of useful & free unity scripts / library’s / plugins and extensions – GitHub – michidk/Unity-Script-Collection: A maintained collection of useful & free unity sc…github.com

    유니티의 심플한 예제들을 정리해놓은 레포지토리

    https://github.com/UnityCommunity/UnityLibraryGitHub – UnityCommunity/UnityLibrary: Library of all kind of scripts, snippets & shaders for Unity:books: Library of all kind of scripts, snippets & shaders for Unity – GitHub – UnityCommunity/UnityLibrary: Library of all kind of scripts, snippets & shaders for Unitygithub.com

    #아직 리서치 필요

    https://github.com/cmilr/Unity2D-ComponentsGitHub – cmilr/Unity2D-Components: A constantly evolving array of Unity C# components for 2D games, including classes for pixelA constantly evolving array of Unity C# components for 2D games, including classes for pixel art cameras, events & messaging, saving & loading game data, collision handlers, object pools, a…github.com

     
    https://wonsorang.tistory.com/657[Unity] 잦은 자동빌드, 느리고 멈출 때 해결법유니티를 사용할 때, 코드 한두줄 바꾸고 유니티 에디터를 잠깐 조작할 일이 종종 생깁니다. 어셋을 확인한다거나 컴포넌트 셋팅을 확인한다거나… 그 때마다 수정된 코드를 변경된 어셋으로wonsorang.tistory.com

  • MV(R)P 설계에 대한 생각


    환경

    UniRx 입문 시리즈 목차는 이쪽

    MVP 패턴이란?

    MVP 패턴에 대한 자료를 찾아보면, 정확한 정의는 없고, 대략적인 공통점들은 있다.

    기본적인 기조는 같지만, 세부 적인 구현 내용은 개발자의 역량에 달린 것으로 보인다.

    대략적으로 MVP 패턴은 아래 특징을 가지고 있다.

    Model – View – Presneter

    Model

    • Data와 관련된 모든 처리를 담당한다. 비즈니스 로직 처리.
      • 비즈니스 로직은 컴퓨터 프로그램에서 실세계의 규칙에 따라 데이터를 생성·표시·저장·변경하는 부분을 일컫는다.

    View

    • 사용자에게 보여지는 UI 부분 (유니티에서는 모든 렌더링 되는 Object)

    Presenter

    • View에서 요청한 정보(User actions)로 Model을 가공하여(Update model) 변경된 Model정보를 받아(Model changed) View에게 전달(Update UI)해주는 부분
    • 접착제 역할

    관계도

    특징

    • View와 Model은 서로를 알지 못한다. (어떤 방법으로든 접근할 수 없다)
    • Presenter은 View와 Model을 알고 있다.

    여기서 알고 있다는 부분에 대한 해석으로 좀 헤매였던 부분이 있었는데, 알고 있다는 부분은 해당 인스턴스를 직접적으로 조작한다로 해석해도 무방할것으로 보인다.

    → 직접 조작하지만 않으면 알고 있지 않은 것 (이벤트 방식, SendMessage 등)

    MV(R)P

    UniRx 플러그인을 사용하면 유니티에서 MVP 패턴을 좀 더 쉽게 구현할 수 있다. (MVP 패턴에서 구현해야 되는 이벤트 기반 코드들을 더 쉽게 사용) 2020년 10월 9일 기준 v7.10(19년 7월 1일) 까지 나와 있는 상태이고, 원래는 Reactive Programming을 유니티에서 쉽게 사용 하기 위해 만들어진 플러그인이다.

    UniRx를 사용하면 MVP (MVRP) 패턴을 구현할 수 있습니다.

    MVVM 대신 MVP를 사용해야하는 이유?

    유니티는 UI 바인딩을 제공하지 않으며, 바인딩 레이어를 만드는 것은 복잡하며, 오버헤드가 크다.

    MVP 패턴을 사용하는 Presenter는 View의 구성요소를 알고 있으며 업데이트 할 수 있다. 실제 바인딩을 하지 않지만, View를 구독(Observable)하여 바인딩 하는 것과 유사하게 동작하게 할 수 있다. (복잡하지 않고, 오버 헤드도 적게 사용 가능)

    이 패턴을 Reactive Presenter라고 한다.

    // Presenter는 씬의 canvas 루트에 존재.
    public class ReactivePresenter : MonoBehaviour
    {
        // Presenter는 View를 알고 있다(인스펙터를 통해 바인딩 한다)
        public Button MyButton;
        public Toggle MyToggle;
        
        // Model의 변화는 ReactiveProperty를 통해 알 수 있다.
        Enemy enemy = new Enemy(1000);
    
        void Start()
        {
            // Rx는 View와 Model의 사용자 이벤트를 제공한다.
            MyButton.OnClickAsObservable().Subscribe(_ => enemy.CurrentHp.Value -= 99);
            MyToggle.OnValueChangedAsObservable().SubscribeToInteractable(MyButton);
    
            // Model들은 Rx를 통해 Presenter에게 자신의 변화를 알리고, Presenter은 Viw를 업데이트 한다.
            enemy.CurrentHp.SubscribeToText(MyText);
            enemy.IsDead.Where(isDead => isDead == true)
                .Subscribe(_ =>
                {
                    MyToggle.interactable = MyButton.interactable = false;
                });
        }
    }
    
    // Model. 모든 프로퍼티는 값의 변경을 알려 준다. (ReactiveProperty)
    public class Enemy
    {
        public ReactiveProperty<long> CurrentHp { get; private set; }
    
        public ReactiveProperty<bool> IsDead { get; private set; }
    
        public Enemy(int initialHp)
        {
            // 프로퍼티 정의
            CurrentHp = new ReactiveProperty<long>(initialHp);
            IsDead = CurrentHp.Select(x => x <= 0).ToReactiveProperty();
        }
    }

    View는 하나의 Scene이며, Unity의 hierarchy이다. (하나의 개체 혹은 객체?)

    View는 초기화시 Unity 엔진에 의해 Presenter와 연결된다.

    XxxAsObservable 메서드를 사용하면 오버 헤드없이 이벤트 신호를 간단하게 생성 할 수 있습니다. SubscribeToText 및 SubscribeToInteractable은 간단한 바인딩 처럼 사용할 수 있게 하는 helper 클래스 입니다. 이것은 단순한 도구 일 수 있지만 매우 강력합니다. Unity 환경에서 자연스럽게 느껴지며 고성능과 깨끗한 아키텍처를 제공합니다.

    MV(R)P
    • V-> RP-> M-> RP-> V가 완전히 Reactive(반응적인)한 방법으로 연결되었다.
    • GUI 프로그래밍은 ObservableTrigger의 이점도 제공합니다. ObservableTrigger는 Unity 이벤트를 Observable로 변환하므로이를 사용하여 MV(R)P 패턴을 구성 할 수 있습니다. 예를 들어 ObservableEventTrigger는 uGUI 이벤트를 Observable로 변환합니다.
    var eventTrigger = this.gameObject.AddComponent<ObservableEventTrigger>();
    eventTrigger.OnBeginDragAsObservable()
        .SelectMany(_ => eventTrigger.OnDragAsObservable(), (start, current) => UniRx.Tuple.Create(start, current))
        .TakeUntil(eventTrigger.OnEndDragAsObservable())
        .RepeatUntilDestroy(this)
        .Subscribe(x => Debug.Log(x));

    설계 방향

    • MVP 패턴을 보면서 헷갈리거나 정립되지 않는 부분은 과감히 내 방식으로 정립하고, 구현 후 문제점 발생시 개선하는 방향으로 진행.
    • 완전히 디자인 패턴을 따르지는 않을 예정 (클린 코드가 되는 대신 생산성이 저하되는 부분은 과감히 생산성을 따르는 방향)
    • 하나의 Presenter에 여러개의 Model이 존재할 수 있다.
      • 각 모델의 경우 역할별로 클래스화 작업.
    • 하나의 Presenter에 여러개의 View가 존재할 수 있다.
    • Presenter는 각 팝업, 각 오브젝트 별로 존재한다. (컴포넌트 개념으로 생각)
      • 팝업의 아이템이 존재한다면 그 아이템도 각각의 Presenter가 존재. 구조가 복잡하지 않는다면 없어도 무방.
    • 간단한 예제에서는 항상 View-Presenter-Model은 1개씩 존재 했기 때문에, 각 Presetenr 1개에 2개이상의 view와 model이 존재해도 문제 없는지에 대한 고민을 함.
    • 그리고 Model의 구현시 거의 모든 역할을 Model에서 한다고 생각하면 될것으로 보임 (Presenter는 Model의 메서드를 호출하는 정도의 역할)
      • 보통의 예제에서는 간단한 메서드 구현 정도는 Presenter에서 해주는 부분도 있지만, Model이 전부 해주는게 더 일반적인 구조인것으로 보임.

  • UniRx의 용도

    환경

    UniRx 입문 시리즈 목차는 이쪽

    현대에서 UniRx를 어떤 경에서 사용하는지 소개합니다. 일부는 예전에는 UniRx를 사용하기도 했지만, 현대에서는 다른 방법으로 쓰는 것이 좋다는 것도 있습니다.

    A. 이벤트 통지에 사용

    이벤트란, “무언가의 조건을 만족했을 때, 그 때의 정보를 통지해 다른 장소에서 처리를 실행한다”라고 하는 구조를 가리킵니다. 이벤트를 이용하면 “조건을 판정하는 부분”과 “실제로 처리를 실시하는 부분”을 분리해 구현할 수 있게 됩니다.

    이벤트의 사용법으로서는 다음의 패턴을 생각할 수 있습니다.

    • 비 정기적으로 여러 번 반복하는 처리를 다루기 쉽다.
    • 실제의 처리 부분이 복수 있을 때, 그 조건 판정의 부분을 한 곳에서 처리한다
    • 컴포넌트간의 종속성 구성

    “비 정기적으로 여러 번 반복하는 처리를 다루기 쉽다”는 Unity에서는 OnTriggerEnter등의 이벤트를 예로 들 수 있습니다. “언제 일어날지 모르지만, 발생했을 때는 즉시 대응한 처리를 실행하고 싶다”라고 하는 때에 이벤트의 개념을 사용할 수 있습니다.

    “실제의 처리 부분이 복수 있을 때, 그 조건 판정의 부분을 한 곳에서 처리한다”는, 예를 들면 “Input”을 들 수 있습니다. 게임을 하고 있는 사람(플레이어)으로부터의 Input을 받아들이고 캐릭터를 조작하게 됩니다. 이 때 아무것도 생각하지 않고 어리석게 구현하면 다양한 컴포넌트와 if(Input.GetKeyDown("Attack"))같은 처리가 흩어져 버립니다. 이러한 문제도 이벤트 개념을 사용하여 스마트하게 구현할 수 있습니다.

    UniRx와 이벤트

    우선 “Input을 판정해 그것을 Observable로 변환하는 컴퍼넌트”를 생각합니다.

    using UniRx;
    using UnityEngine;
    
    namespace Events
    {
        public sealed class InputEventProvider : MonoBehaviour
        {
    /// <summary>
    /// 공격 버튼 입력
    /// </summary>
            public IReadOnlyReactiveProperty<bool> Attack => _attack;
    
    /// <summary>
    /// 이동 방향 입력
    /// </summary>
            public IReadOnlyReactiveProperty<Vector3> MoveDirection => _moveDirection;
    
    /// <summary>
    /// 점프 입력
    /// </summary>
            public IReadOnlyReactiveProperty<bool> Jump => _jump;
    
    // 구현
            private readonly ReactiveProperty<bool> _attack = new BoolReactiveProperty();
            private readonly ReactiveProperty<bool> _jump = new BoolReactiveProperty();
            private readonly ReactiveProperty<Vector3> _moveDirection = new ReactiveProperty<Vector3>();
    
            private void Start()
            {
    // Destroy시 Dispose()
                _attack.AddTo(this);
                _jump.AddTo(this);
                _moveDirection.AddTo(this);
            }
    
            private void Update()
            {
    // 다양한 입력을 ReactiveProperty에 반영
                _jump.Value = Input.GetButton("Jump");
                _attack.Value = Input.GetButton("Attack");
                _moveDirection.Value = new Vector3(
                    x:Input.GetAxis("Horizontal"),
                    y:0,
                    z:Input.GetAxis("Vertical"));
            }
        }
    }
    

    이 컴퍼넌트를 준비하면, 실제로 입력 이벤트를 사용해 처리를 실시하는 컴퍼넌트로부터 이것을 참조시킵니다. 이제 “UniRx를 사용하여 입력 이벤트를 처리”할 수있었습니다.

    using System;
    using UniRx;
    using UnityEngine;
    
    namespace Events
    {
    /// <summary>
    /// 예: Input을 보고 이동
    /// </summary>
        public class PlayerMove : MonoBehaviour
        {
            [SerializeField] private float _moveSpeed = 1.0f;
            [SerializeField] private InputEventProvider _inputEventProvider;
    
            private CharacterController _characterController;
    
            private void Start()
            {
                _characterController = GetComponent<CharacterController>();
    
    // 점프
    // 점프 버튼 입력 이벤트 결정
                _inputEventProvider.Jump
    // 버튼을 눌렀을 때,
                    .Where(x => x)
    // 접지 중이며,
                    .Where(_ => _characterController.isGrounded)
    // 마지막으로 점프한 지 1초 이상 경과하면,
                    .ThrottleFirst(TimeSpan.FromSeconds(1))
                    .Subscribe(_ =>
                    {
    // 점프 처리 수행
                        Jump();
                    });
    
    // 이동 처리
                _inputEventProvider
                    .MoveDirection
    // 일정값 이상 입력하면
                    .Where(x=>x.magnitude > 0.5f)
                    .Subscribe(x =>
                    {
    // 그쪽으로 이동
                        _characterController.Move(x.normalized * _moveSpeed);
                    });
            }
    
            private void Jump()
            {
    // 점프 처리(생략)
            }
        }
    }
    

    “Pub/Sub” 개념

    이벤트 처리의 일종으로서, “Pub/Sub”라고 하는 것이 있습니다.

    일반적인 이벤트 처리에 있어서는, 일반적으로는 “누구로부터 메세지가 보내 오는지”를 구독측이 어느 정도는 의식할 필요가 있습니다. 그것을 Pub/Sub 있어서는 “이벤트 메세지 그 자체”에 주목해, “누구로부터 보내져 왔는지는 신경쓰지 않는다”라고 하는 모델이 되고 있습니다. (같이 송신측도 “누가 수신하고 있는지는 신경쓰지 않는다”라고 하는 형태가 됩니다)

    Pub/Sub를 사용하면 송신자와 수신자를 더 느슨하게 결합할 수 있습니다. 컴퍼넌트간의 참조 관계나 의존관계를 정리해 휘두르는 일 없이, 보다 데이터 플로우를 중심으로 한 구현을 실시하는 것이 가능해집니다. 그러나 다른 한편으로는 올바르게 메시지를 관리 할 수 ​​없으면 스파게티 코드가 가속된다는 문제점도 Pub/Sub있습니다. 초보자에게 추천할 수 있는 기능은 아닙니다만, 이런 것도 있으면 머리의 한쪽 구석에서 두면 어느 것이 도움이 될 때가 올 것입니다.

    이제 이것 Pub/Sub이지만 구현하는 방법에는 여러 가지가 있습니다.

    • UniRx의 “MessageBroker”라는 기능 사용
    • MessagePipe 라는 라이브러리 사용

    Pub/Sub을 가볍게 시도하고 싶다면 UniRx MessageBroker를 쉽게 사용할 수 추천합니다. 거기에서 한층 더 밟아, “DI와 조합해 사용하고 싶다” “서버 통신을 얽힌 Pub/Sub것을 실시하고 싶다”라고 하는 경우는 MessagePipe를 사용해 보면 좋을 것입니다.

    B. 비동기 처리에 사용

    앞에서 결론에서 언급하면 ​​비동기 처리에 UniRx를 사용하는 것은 더 이상 권장 하지 않습니다. 과거 2017년 이전의 Unity에서는 비동기 처리의 선택사항으로서 괜찮은 것이 UniRx 정도밖에 없었습니다. 그러나 현대에서는 async/await나 UniTask의 등장에 의해, 굳이 UniRx를 사용해 비동기 처리를 취급할 필요성이 없어졌습니다.

    (원래 「비동기 처리」란 무엇을 가리키는지입니다만, 이쪽은 말하면 길어지기 때문에 다른 기사에서 다시 투고 예정입니다)

    추가

    “비동기 처리에 UniRx를 사용하는 것은 비추천”이지만 일부 Operator가 매우 편리합니다. 특히 OnErrorRetry는 에러 발생시 지정 횟수까지 재 시도를 해주는 것입니다. 이런 Operator를 async/await 함께 try-catch 사용하면 상당히 복잡해지기 때문에, async/await와 함께 Observable을 같이 사용하는 것도 어려울 수 있지만 존재합니다.

    단, 어리석게 ToObservable().OnErrorRetry()만 해도 잘 움직이지 않고, Observable.Defer()와 병용할 필요가 있거나 합니다. 이 함수들은 상당히 까다롭기 때문에, 왜 Observable.Defer() 필요한지 모르는 사람은, 이 테크닉은 사용하지 않는 편이 안전할지도 모릅니다.

    private async UniTaskVoid SampleAsync(string uri, CancellationToken token)
    {
        var result =
        // Observable.Defer로 감싸서 Retry 발화시 GetAsync를 다시 실행하도록 합니다.
        await Observable.Defer(() => GetAsync(uri, token).ToObservable())
        // 에러 발생시 1초 기다린 후 총 3회까지 시도
        .OnErrorRetry((UnityWebRequestException ex) => Debug.LogException(ex), retryCount: 3, TimeSpan.FromSeconds(1));
    
        Debug.Log(result);
    }
    
    private async UniTask<string> GetAsync(string uri, CancellationToken token)
    {
        using (var uwr = UnityWebRequest.Get(uri))
        {
            await uwr.SendWebRequest().WithCancellation(token);
            return uwr.downloadHandler.text;
        }
    }

    예: AsyncOperation

    Unity에서 등장하는 비동기 처리로 취급할 필요가 있는 객체 중:AsyncOperation가 있습니다. 이것은 UnityAPI에서 비동기 처리를 호출할 때 반환되는 객체입니다.

    AsyncOperation한 객체를 돌려주는 Unity의 API의 예】

    • UnityWebRequest.SendWebRequest()
    • SceneManager.LoadSceneAsync()
    • AssetBundle.LoadAssetAsync

    AsyncOperation는 본래라면 코루틴으로 처리하는 객체입니다만, UniTask를 도입하고 있는 경우는 async/await로 기술하는 것이 가능합니다.

    // 텍스처 다운로드
    public async UniTask<Texture> FetchTextureAsync(string uri, CancellationToken token)
    {
        using (var uwr = UnityWebRequestTexture.GetTexture(uri))
        {
            await uwr.SendWebRequest().WithCancellation(token);
            return ((DownloadHandlerTexture) uwr.downloadHandler).texture;
        }
    }

    (이하 비추천)

    한때, UniRx 정도 괜찮은 비동기 처리의 핸들링 방법이 없었던 시대는 다음과 같은 쓰기를 하고 있었습니다. async/await와 비교해 보면, UniRx 쪽이 압도적으로 중복코드가 많고 복잡합니다.

    /// <summary>
        /// 텍스처 다운로드 Observable
        /// </summary>
        public IObservable<Texture> FetchTextureObservable(string uri)
        {
            // 코루틴을 Observable로 변환
            return Observable.FromCoroutine<Texture>(observer =>
            FetchTextureCoroutine(uri, observer));
        }
    
        /// <summary>
        /// 통신하는 코루틴
        /// </summary>
        private IEnumerator FetchTextureCoroutine(
            string uri,
            IObserver<Texture> observer)
        {
            using (var uwr = UnityWebRequestTexture.GetTexture(uri))
            {
                yield return uwr.SendWebRequest();
    
            if (uwr.result != UnityWebRequest.Result.Success)
            {
                observer.OnError(new Exception(uwr.error));
            }
            else
            {
                var result = ((DownloadHandlerTexture) uwr.downloadHandler).texture;
                observer.OnNext(result);
                observer.OnCompleted();
            }
        }
    }

    C.Model-View-(Reactive)Presenter 패턴에 사용

    Model-View-(Reactive)Presenter, 통칭 MV(R)P패턴은 Unity에서 주로 UI 구현에서 사용되는 경우가 많은 패턴입니다. View와 Model 2개의 오브젝트를 UniRx를 이용해 연결하는 구현 패턴이 됩니다.

    D.MonoBehaviour의 로직 작성

    UniRx에 존재하는 UpdateAsObservable()와 Observable.EveryUpdate() 애용하고 있는 분은 많을 것입니다. 여기에 대해서는 UniRx를 그대로 사용해도 되지만, 경우에 따라서는 async/await쪽이 간단하게 구현 가능한 경우도 있다고 기억해 두면 좋을 것입니다.

    간단한 로직을 UniRx로 작성

    예로서 다음 로직을 생각해 봅시다.

    조건이 충족되면 처리를 실행한 후 몇 초 동안 쿨타임에 들어갑니다. (공격을 내면 그 후 몇 초간은 다시 공격을 할 수 없다, 같다)

    이것을 UniRx로 작성하면 다음과 같습니다.

    using System;
    using UniRx;
    using UniRx.Triggers;
    using UnityEngine;
    
    namespace Samples
    {
        public sealed class SimpleLogicUniRx : MonoBehaviour
        {
            private void Start()
            {
                // 매 프레임 실행
                this.UpdateAsObservable()
                    // 공격 버튼을 누르면
                    .Where(_ => Input.GetButtonDown("Attack"))
                    // 처리를 1회 실행시킨 후, 1초간 쿨타임
                    .ThrottleFirst(TimeSpan.FromSeconds(1))
                    .Subscribe(_ => Action())
                    .AddTo(this);
            }
    
            /// <summary>
            /// 정기적으로 실행되는 처리
            /// </summary>
            private void Action()
            {
                // 공격
                Debug.Log("Action!");
            }
        }
    }

    이러한 「오퍼레이터를 조합하는 것만으로 구현할 수 있는 처리」에 대해서는 UniRx를 사용해 써도 문제 없습니다.

    UniRx로 작성하기 어려운 경우

    UniRx의 장점은 「풍부한 오퍼레이터를 사용할 수 있다」입니다만, 반대로 단점으로서 오퍼레이터의 범위 외의 처리는 굉장히 구현하기 어려워 집니다.

    조금 전의 예의 「조건을 만족하면 뭔가 처리를 실행해 그 후 몇 초간 쿨타임에 들어간다」를 조금 확장해, 다음과 같은 처리를 생각해 봅시다.

    • 조건 A를 만족하면 처리 X를 호출하고 N 초 동안 쿨타임에 들어갑니다.
    • 조건 B를 만족하면 처리 Y를 호출하고 M 초 동안 쿨타임에 들어갑니다.
    • 처리 X와 Y는 각각 배타적이며, 서로의 쿨타임 중에는 서로의 처리가 차단된다.

    알기 쉽게 바꿔 말한다면, “강 공격을 내면 쿨타임이 길다. 약공격을 내면 쿨타임이 짧다. 쿨타임 중에는 공격을 일절 할 수 없다”같은 패턴입니다.

    자, 이것을 UniRx만으로 작성하려고하면 어떻게 될까요? 오퍼레이터의 단순한 연결만으로는 구현할 수 없고, 상당히 얽힌 코드로 구현 코드가 나올 것 같습니다. 이러한 도중에 조건 분기가 들어가거나 조건에 따라 처리 내용이 크게 바뀌는 것을 UniRx는 매우 구현하기 힘듭니다.

    이러한 경우는 UniRx를 사용하지 않고 async/await(또는 코루틴)으로 써 버리는 쪽이 결과적으로 깨끗하게 구현할 수 있습니다.

    using System.Collections;
    using UnityEngine;
    
    namespace Samples
    {
        public sealed class ComplexLogicCoroutine : MonoBehaviour
        {
            private void Start()
            {
                StartCoroutine(LogicLoop());
            }
    
            private IEnumerator LogicLoop()
            {
                // Destroy 될 때까지 무한 루프
                while (true)
                {
                    if (Input.GetButtonDown("AttackA"))
                    {
                        // 입력 A가 실행되면 처리 X를 호출하여 1초 대기
                        ActionX();
                        yield return new WaitForSeconds(1);
                    }
                    else if (Input.GetButtonDown("AttackB"))
                    {
                        // 입력 B가 실행되면 처리 Y를 호출하여 2초 대기
                        ActionY();
                        yield return new WaitForSeconds(2);
                    }
                    else
                    {
                        // 입력이 없으면 1프레임 대기
                        yield return null;
                    }
                }
            }
    
            private void ActionX()
            {
                Debug.Log("do X!");
            }
    
            private void ActionY()
            {
                Debug.Log("do Y!");
            }
        }
    }
    using System;
    using System.Threading;
    using Cysharp.Threading.Tasks;
    using UnityEngine;
    
    namespace Samples
    {
        public sealed class ComplexLogicUniTask : MonoBehaviour
        {
            private void Start()
            {
    // CancellationToken생성
                var token = this.GetCancellationTokenOnDestroy();
    
    // 루프 시작
                LogicLoopAsync(token).Forget();
            }
    
            private async UniTaskVoid LogicLoopAsync(CancellationToken ct)
            {
    // Destroy 될 때까지 무한 루프
                while (!ct.IsCancellationRequested)
                {
                    if (Input.GetButtonDown("AttackA"))
                    {
    // 입력 A가 실행되면 처리 X를 호출하여 1초 대기
                        ActionX();
                        await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: ct);
                    }
                    else if (Input.GetButtonDown("AttackB"))
                    {
    // 입력 B가 실행되면 처리 Y를 호출하여 2초 대기
                        ActionY();
                        await UniTask.Delay(TimeSpan.FromSeconds(2), cancellationToken: ct);
                    }
                    else
                    {
    // 입력이 없으면 1프레임 대기
                        await UniTask.Yield();
                    }
                }
            }
    
            private void ActionX()
            {
                Debug.Log("do X!");
            }
    
            private void ActionY()
            {
                Debug.Log("do Y!");
            }
        }
    }
    

    역주

    • GetCancellationTokenOnDestroy은 MonoBehaviour의 라이프 사이클과 동일하게 CancellationToken이 생성된다.

    MonoBehaviour의 로직 작성 요약

    • Update() FixedUpdate()에 관련된 로직은 UniRx로 기술할 수 있다
    • 다만 UniRx를 사용하는 경우는 기존의 오퍼레이터로 구현할 수 있는 범위 내에 두어 둔다
    • 조금이라도 UniRx로 쓸 수 없다고 느끼면 곧바로 포기하고 코루틴이나 async/await에 다시 쓰는 편이 최종적으로 읽기 쉬워진다

    E. Update()와 같은 범위 분리

    UniRx의 사용법으로서 “Update()등의 스코프를 분리한다”가 있습니다.

    • Update()와 FixedUpdate() 등의 처리를 문맥 별로 분리한다
    • Update()나 FixedUpdate() 실행 시작 타이밍 조정

    이 경우 UniRx를 사용할 수 있습니다.

    예: Update()를 문맥별로 분리

    예를 들면 다음과 같이, Update()에 복수의 처리를 하는 코드가 있었다고 합니다.

    using UnityEngine;
    
    namespace Samples
    {
        public sealed class SamplePlayer : MonoBehaviour
        {
    // 여기에 많은 필드 변수가 정의
            private void Update()
            {
    // 공격 처리에 따른 처리
                CheckAttack();
    
    // Player의 위치에 따른 처리
                CheckPosition();
    
    // Player의 체력에 따른 처리
                CheckHealth();
    
    // 움직임 처리
                Move();
            }
    
    // ↓ 메서드가 줄지어 정의되어있다가 가정한다.
        }
    }
    

    그런데, 이 Update()문을 보고 느끼는 것은 없을까요. “이 처리의 순서는 바꿔도 문제 없을까?”, “어떤 처리와 어떤 처리는 연관된 처리가 있는가?” 라는 의문이 나올 수 있습니다.

    이러한 처리 순서에 따라 제대로 동작하지 않는 메서드가 있을 수 있고, 처리 순서가 무방한 메서드가 있을 수 있습니다. 이러한 부분이 주석에 써 있을지도 모르고, 전혀 주석이 없는 코드일지도 모릅니다. Update()라는 하나의 메소드에 나란히 쓰고 있는 이상은, 항상 이러한 것을 의식해 코드를 작성/읽어야 합니다.

    이러한 문제는 UniRx를 사용하면 다소 개선할 수 있습니다.

    using System;
    using UniRx;
    using UniRx.Triggers;
    using UnityEngine;
    
    namespace Samples
    {
        public sealed class SamplePlayer : MonoBehaviour
        {
            private void Start()
            {
    // Player의 위치에 따른 처
                this.UpdateAsObservable()
                    .Subscribe(_ => CheckPosition())
                    .AddTo(this);
    
    // Player의 체력에 따른 처리
                this.UpdateAsObservable()
                    .Subscribe(_ => CheckHealth())
                    .AddTo(this);
    
    // 공격 처리 확인 및 이동 처리
                this.UpdateAsObservable()
                    .Subscribe(_ =>
                    {
                        CheckAttack();
                        Move();
                    })
                    .AddTo(this);
            }
    
    // ↓ 메서드가 줄지어 있다고 가정한다.
    
    /*
            * 생략
            */
        }
    }
    

    이와 같이, 각 처리마다 따로 따로 Observable 저장하는 것으로 처리 끼리의 스코프를 명확하게 구분 할 수 있었습니다. 또 처리마다 Observable가 분리되어 있기 때문에, 일부 처리만 오퍼레이터를 추가하거나 조정도 쉬워집니다.

    예: 일부 처리만 실행 시작 타이밍을 어긋나기

    Update()를 각 처리마다 Observable 나누어 버리는 이점으로서, “실행 개시 타이밍을 조정하기 쉽다”라고 하는 것이 있습니다.

    예를 들어 특정의 메소드가 불려 갈 때까지는 Update()의 처리의 일부를 스킵 해 두고 싶다고 하는 경우입니다. (적 캐릭터가 화면에 비칠 때까지 이동 처리를 멈추고 싶은 등)

    using UniRx;
    using UniRx.Triggers;
    using UnityEngine;
    
    namespace Samples
    {
        public sealed class SampleEnemy : MonoBehaviour
        {
            private void Start()
            {
    // 화면에 비치면 초기화 처리를 호출합니다.
                this.OnBecameVisibleAsObservable()
                    .Take(1)
                    .Subscribe(_ => Initialize())
                    .AddTo(this);
            }
    
    /// <summary>
    /// 초기화 처리
    /// </summary>
            private void Initialize()
            {
    // 매 프레임 Move()를 호출하도록 설정
                this.UpdateAsObservable()
                    .Subscribe(_ => Move())
                    .AddTo(this);
            }
    
    /// <summary>
    /// 이동 처리
    /// </summary>
            private void Move()
            {
    // 이동 처리
            }
        }
    }
    

    Update()와 같은 범위를 분리합니다.

    이것은 UniRx가 처리를 모두 Observable라고 하는 객체에 감싸는 성질을 이용한, 약간의 테크닉입니다. 기억해두면 사용하기 편리하기 때문에 개인적으로는 가끔 사용하는 기술 이기도 합니다.

    덧붙여 이 기술을 async/await + UniTask로 사용하는 것도 가능합니다. 다만 UniRx와 비교해 이쪽은 기술량이 증가해 코드가 다소 복잡해 집니다. 그 때문에 이 용도에 있어서는 UniRx의 사용이 더 편리한 것으로 보입니다.

    private void Start()
    {
        var token = this.GetCancellationTokenOnDestroy();
    
        UniTask.Void(async () =>
        {
            while (!token.IsCancellationRequested)
            {
                CheckPosition();
                await UniTask.Yield();
            }
        });
    
        UniTask.Void(async () =>
        {
            while (!token.IsCancellationRequested)
            {
                CheckHealth();
                await UniTask.Yield();
            }
        });
    
        UniTask.Void(async () =>
        {
            while (!token.IsCancellationRequested)
            {
                CheckAttack();
                Move();
                await UniTask.Yield();
            }
        });
    }
    

    F. 종속성을 정리하는 데 사용

    UniRx는 종속성을 구성하는 데 사용할 수 있습니다. (UniRx의 기능보다는 Observer 패턴의 성질 그 자체)

    방금 설명한 「이벤트 처리」와 같은 이야기입니다. (시점이 의존 관계로 정리가 되어 있을 뿐, 하고 있는 것은 이벤트 처리 그 자체)

    예: Player 및 PlayerManager

    예를 들어, 다음과 같은 경우를 생각해 봅시다.

    • PlayerManagerPlayer 생성 및 수명주기 관리
    • Player: 자신이 죽은 것을 PlayerManager 알려준다.

    자, 이것을 생각 없이 그대로 구현하면 다음과 같은 코드가 될 것입니다.

    using UnityEngine;
    
    namespace Samples2
    {
        public class PlayerManager : MonoBehaviour
        {
    // Player의 Prefab
            [SerializeField] private Player _playerPrefab;
    
    // 지금 존재하는 플레이어의 실체
            private Player _currentPlayer;
    
            private void Start()
            {
                CreatePlayer();
            }
    
            public void OnPlayerDead()
            {
    // 플레이어가 죽었을 때의 처리가 여기에
                _currentPlayer = null;
    
    // 새로운 플레이어 생성
                CreatePlayer();
            }
    
            private void CreatePlayer()
            {
    // 플레이어 생성
                _currentPlayer = Instantiate(_playerPrefab);
    
    // Player에게 Manager를 가르치기
                _currentPlayer.Initialize(this);
            }
        }
    }
    
    using UnityEngine;
    
    namespace Samples2
    {
        public class Player : MonoBehaviour
        {
            private PlayerManager _playerManager;
    
            public void Initialize(PlayerManager playerManager)
            {
    // 초기화 시 Manager 유지
                _playerManager = playerManager;
            }
    
            private void OnDestroy()
            {
    // 이번에는 OnDestroy되면 '사망'이라는 취급으로
                _playerManager.OnPlayerDead();
            }
        }
    }
    

    이 코드에는 하나의 큰 문제가 있습니다. 그것은 Player와 PlayerManager가 상호 참조하는 것입니다.

    상호 참조는 나중에 스파게티 코드로 발전 할 위험이 매우 높습니다. 따라서 가능한 한 상호 참조를 제거해야합니다.

    그래서 이것을 UniRx (또는 UniTask)를 사용하여 정리해 보겠습니다.

    UniRx를 사용하여 종속성 구성

    UniRx Observable를 이용하면 참조 관계를 일방통행으로 정리할 수 있습니다.

    using UniRx;
    using UnityEngine;
    
    namespace Samples2
    {
        public class PlayerManager : MonoBehaviour
        {
    // Player의Prefab
            [SerializeField] private Player _playerPrefab;
    
    // 지금 존재하는 플레이어의 실체
            private Player _currentPlayer;
    
            private void Start()
            {
                CreatePlayer();
            }
    
            private void OnPlayerDead()
            {
    // 플레이어가 죽었을 때의 처리가 여기에
                _currentPlayer = null;
    
    // 새로운 플레이어 생성
                CreatePlayer();
            }
    
            private void CreatePlayer()
            {
    // 플레이어 생성
                _currentPlayer = Instantiate(_playerPrefab);
    
    // 생성된 플레이어를 모니터링하고
    // 사망 이벤트가 오면 OnPlayerDead 실행
                _currentPlayer
                    .PlayerDeadAsync
                    .Subscribe(_ => OnPlayerDead())
                    .AddTo(this);
            }
        }
    }
    
    using System;
    using UniRx;
    using UnityEngine;
    
    namespace Samples2
    {
        public class Player : MonoBehaviour
        {
    /// <summary>
    /// 플레이어가 사망한 통지를 발행하는 Observable
    /// </summary>
            public IObservable<Unit> PlayerDeadAsync => _playerDeadSubject;
    
    // 1회만 통지를 발행하는 경우에는 AsyncSubject가 편리
            private readonly AsyncSubject<Unit> _playerDeadSubject = new AsyncSubject<Unit>();
    
            private void OnDestroy()
            {
    // 플레이어가 사망한 통지 발행
                _playerDeadSubject.OnNext(Unit.Default);
                _playerDeadSubject.OnCompleted();
    
                _playerDeadSubject.Dispose();
            }
        }
    }
    

    UniRx Observable를 사용하여 알림 흐름을 정리하고 참조 관계를 일방통행으로 만들 수 있습니다. 원래 Observable는 “불특정 다수에게 자신의 상태를 감시하게 한다”라는 용도를 위한 기능입니다. 그러므로 이러한 “뭔가 상태가 변화했을 때 그것을 상대에게 전달한다”는 경우에서는 Observable 강력하게 작용합니다.

    응용 프로그램 : 상태 알림

    Observable의 “불특정 다수에게 자신의 상태를 감시시킨다”라고 하는 용도를 좀 더 생각해 보겠습니다.

    예를 들어 액션 게임에서 “뭔가 이벤트가 일어났을 때 그에 따라 복수의 처리를 동시에 실행시킨다”라는 패턴은 자주 존재합니다. 이러한 처리를 구현하는 경우, 이벤트 발행측이 통지처를 모두 파악하는 코드는 매우 파악하기 어렵게 됩니다.

    예로서 다음과 같은 구현이 있다고 합니다.

    • 플레이어가 데미지를 받으면 다음 처리가 실행됩니다.
      • 체력 감소
      • 넉백 효과
      • 데미지 애니메이션 재생
      • 파티클 이펙트가 나온다
      • 효과음 재생
      • 디버깅 할 때만 UI에 숫자를 표시하고 싶습니다.

    이것을 “데미지를 받았을 때에 하나하나 통지처의 메소드를 호출해 간다”라고 하는 구현으로 하면 이렇게 됩니다.

    이 구현의 약점은 “PlayerCore 모두를 관리하지 않으면 안된다”라고 하는 점에 있습니다. 통지처를 모든 PlayerCore 것이 알고 있는 상태로 해, 게다가 상황에 따라서 통지한다/안한다 판단을 PlayerCore하지 않으면 안됩니다. 이에 대한 PlayerCore 책임이 커지고, 점점 복잡한 코드로 성장해 버립니다.


    이러한 문제는 Observable를 사용하여 해결할 수 있습니다.

    PlayerCore에 Observable 정의하고 각 컴포넌트가 필요에 따라 Subscribe 하는 형식으로 해 줍니다. 이렇게 하면 PlayerCore 책임이 크게 줄어들고 “이벤트 알림을 처리하는 방법”의 책임이 각 구성 요소에 분산됩니다.

    UniTask를 사용하여 종속성 구성

    UniTask 및 async/await를 사용하여 종속성을 구성할 수 있습니다. 단, UniRx와 달리 UniTask는 “이벤트 통지 횟수가 1회에 한정한다” 경우에만 이용 가능합니다.

    using Cysharp.Threading.Tasks;
    using UnityEngine;
    
    namespace Samples2
    {
        public class Player : MonoBehaviour
        {
            /// <summary>
            /// 플레이어가 사망하면 완료되는 UniTask
            /// </summary>
            public UniTask PlayerDeadAsync => _playerDeadUtc.Task;
    
            private readonly UniTaskCompletionSource _playerDeadUtc = new UniTaskCompletionSource();
    
            private void OnDestroy()
            {
                // 플레이어가 사망하면 UniTask 완료
                _playerDeadUtc.TrySetResult();
            }
        }
    }
    using System.Threading;
    using Cysharp.Threading.Tasks;
    using UnityEngine;
    
    namespace Samples2
    {
        public class PlayerManager : MonoBehaviour
        {
            // Player의Prefab
            [SerializeField] private Player _playerPrefab;
    
            // 지금 존재하는 플레이어의 실체
            private Player _currentPlayer;
    
            private void Start()
            {
                SetupPlayerAsync(this.GetCancellationTokenOnDestroy()).Forget();
            }
    
            private void OnPlayerDead()
            {
                // 플레이어가 죽었을 때의 처리가 여기에
                _currentPlayer = null;
    
                // 새로운 플레이어 생성
                SetupPlayerAsync(this.GetCancellationTokenOnDestroy()).Forget();
            }
    
            private async UniTaskVoid SetupPlayerAsync(CancellationToken token)
            {
                // 플레이어 생성
                _currentPlayer = Instantiate(_playerPrefab);
    
                // 생성된 플레이어를 모니터링하고
                // 사망하면 OnPlayerDead 실행
                await _currentPlayer.PlayerDeadAsync;
                token.ThrowIfCancellationRequested();
    
                OnPlayerDead();
            }
        }
    }

    종속성을 정리하는 데 사용되는 요약

    UniRx를 사용하면 종속성을 정리하고 이벤트 흐름을 개선할 수 있습니다. 또 통지 회수가 1회에 한한 경우에 있어서는 “UniRx”나 “UniTask+async/await”의 어느 쪽에서도 기술하는 것도 가능합니다.

    덧붙여 개인적으로는, 통지 회수가 1회만이라면 UniTask+async/await, 몇번이나 통지한다면 Observable로 구분해서 사용 하고 있습니다. Observable라고 하는 형태만을 보았을 때에, “이것은 Observable 몇번 이벤트를 발행하는 것일까”를 모릅니다. 그렇다면 “UniTask 그렇다면 절대 많아도 1회 밖에 발행되지 않는다”, “Observable 그렇다면 복수회 발행될 것이다” 라고 나누는 것이 편하기 때문입니다. (상대측의 구현 내용을 상상해 쓰는, 같은 불필요한 일 하지 않아도 된다)

    G. 스크립트 실행 순서를 조정하는 데 사용

    Unity에는 Script Execution Order 라는 스크립트 실행 순서를 조정하는 메커니즘이 있습니다. Script Execution Order은 마지막 보루로 남겨 두어야 할 정도로 안이하게 만져서는 안되는 기능입니다.

    그렇다면 실행 순서를 관리하고 싶을 때 어떻게해야할까요? UniRx를 사용하여 이벤트에서 작동하도록 해 버리는 것입니다. 즉, “전의 컴퍼넌트가 끝나면 다음의 컴퍼넌트가 연쇄해 실행된다”라고 하는 구조로 합니다. 이렇게 하면 컴포넌트 간의 호출 순서 Update()나 FixedUpdate() 생각하지 않아도 됩니다.

    예 : UniRx로 구성 요소 간의 타이밍 조정

    예를 들어, 다음과 같은 구현을 생각해 보겠습니다.

    • PlayerInput에서 버튼 입력을 수락
    • 버튼 입력을 확인하고 PlayerMoveController 점프 처리를 수행합니다.
    • 점프 상태가 되면 PlayerAnimation 점프 애니메이션 재생

    “각 컴퍼넌트가 Update()를 실행해, 조건을 만족하면 각 처리를 실행한다” 라고 하는 어리석은 구현도 가능합니다. 하지만 이 방법의 경우, 실행 순서가 어긋나서 처리가 1프레임 늦어지거나, 상태 체크를 흘리는 등의 문제가 일어날 수 있습니다.

    그 때문에 그러한 것을 Update() 사용하는 코드를 멈추고 UniRx를 이용해 처리가 연쇄하는 구현 으로 해 보겠습니다.

    using UniRx;
    using UnityEngine;
    
    namespace Samples3
    {
    /// <summary>
    /// 입력 이벤트 관리
    /// </summary>
        public sealed class PlayerInput : MonoBehaviour
        {
    /// <summary>
    /// 점프 버튼의 입력 상태를 나타내는 ReactiveProperty
    /// </summary>
            public IReadOnlyReactiveProperty<bool> JumpButton => _jumpButton;
    
            private readonly ReactiveProperty<bool> _jumpButton = new ReactiveProperty<bool>(false);
    
    /*
            * 그 밖에도 여러 가지 이벤트가 줄지어 있지만 생략
            */
    
            private void Start()
            {
    // Destroy 시에 Dispose 되도록(듯이) 한다
                _jumpButton.AddTo(this);
            }
    
            private void Update()
            {
    // 버튼 상태를 반영하여 알림
                _jumpButton.Value = Input.GetButton("Jump");
            }
        }
    }
    
    using System;
    using UniRx;
    using UniRx.Triggers;
    using UnityEngine;
    
    namespace Samples3
    {
        public sealed class PlayerMoveController : MonoBehaviour
        {
    /// <summary>
    /// 접지 되었습니까?
    /// </summary>
            public IReadOnlyReactiveProperty<bool> IsGrounded => _isGrounded;
    
            private readonly ReactiveProperty<bool> _isGrounded = new ReactiveProperty<bool>(false);
    
    /// <summary>
    /// 점프 이벤트
    /// </summary>
            public IObservable<Unit> OnJump => _jumpSubject;
    
            private readonly Subject<Unit> _jumpSubject = new Subject<Unit>();
    
            [SerializeField] private LayerMask _groundLayerMask;
            [SerializeField] private PlayerInput _playerInput;
            private Rigidbody _rigidbody;
    
            private void Start()
            {
                _isGrounded.AddTo(this);
                _jumpSubject.AddTo(this);
                _rigidbody = GetComponent<Rigidbody>();
    
    // 접지 상태 확인
                this.FixedUpdateAsObservable()
                    .Subscribe(_ =>
                    {
    // 레이를 발밑으로 날리다
                        _isGrounded.Value = Physics.SphereCast(origin: transform.position + Vector3.up * 0.04f,
                        radius: 0.02f,
                        direction: Vector3.down, hitInfo: out var _, maxDistance: 0.05f, _groundLayerMask);
                    })
                    .AddTo(this);
    
    
    // 입력 이벤트 처리
                _playerInput.JumpButton
    // 점프 버튼을 누른 순간에 접지하면 실행
                    .Where(x => x && _isGrounded.Value)
    // Input 이벤트는 Update() 타이밍이므로,
    // 다음 FixedUpdate 타이밍으로 타이밍 조정
                    .ObserveOnMainThread(MainThreadDispatchType.FixedUpdate)
                    .Subscribe(_ =>
                    {
    // 점프실행
                        Jump();
                    })
                    .AddTo(this);
            }
    
            private void Jump()
            {
                _rigidbody.AddForce(Vector3.up * 10.0f, ForceMode.VelocityChange);
    
    // 점프 이벤트 발행
                _jumpSubject.OnNext(Unit.Default);
            }
        }
    }
    
    using UniRx;
    using UnityEngine;
    
    namespace Samples3
    {
        public sealed class PlayerAnimation : MonoBehaviour
        {
            [SerializeField] private Animator _animator;
            [SerializeField] private PlayerMoveController _moveController;
    
    /// <summary>
    /// 점프 애니메이션 재생
    /// </summary>
            private bool IsJumping
            {
                set => _animator.SetBool("Jumping", value);
            }
    
            private void Start()
            {
    // 점프 이벤트가 오면 점프 애니메이션 시작
                _moveController.OnJump.Subscribe(_ => IsJumping = true).AddTo(this);
    
    // 접지하면 점프 애니메이션 해제
                _moveController.IsGrounded.Where(x => x).Subscribe(_ => IsJumping = false).AddTo(this);
            }
        }
    }
    

    UniRx를 사용하면 3개의 컴포넌트가 충돌 없이 순서대로 동작시킬 수 있게 되었습니다. 이와 같이 각 컴퍼넌트로부터 Update()와 FixedUpdate()를 최대한 배제하는 것으로, 동작 순서를 완전하게 제어할 수 있게 됩니다. (정확히 반응)

    역주

    • ObserveOnMainThread(MainThreadDispatchType.FixedUpdate)을 사용하여 FixedUpdate에서 점프 처리를 실행하도록 처리하였다.

    예: 초기화 순서 조정(async/await)

    실행 순서의 조정 Update()와 FixedUpdate() 어떠한 것에 한정되지 않고, 초기화(Start()나 Awake())의 순서도 중요하게 되는 일도 있습니다. 이러한 상황에서도 UniRx를 사용할 수 있습니다. 그렇지만, “초기화”라고 하는 기본적으로 1회 밖에 실행되지 않는 처리에 대해서는 “async/await+UniTask”로 쓰는 편이 깨끗해집니다.

    using System.Linq;
    using System.Threading;
    using Cysharp.Threading.Tasks;
    using UnityEngine;
    
    namespace Samples3
    {
        public sealed class EnemyManager : MonoBehaviour
        {
            [SerializeField] private Enemy _enemyPrefab;
    
            /// <summary>
            /// 적을 복수체 생성하여 초기화
            /// </summary>
            public async UniTask InitializeAsync(CancellationToken token)
            {
                // 10개 생성하고 모든 초기화가 끝날 때까지 기다린다
                // (UniTask.WhenAll을 암시 적으로 호출 중)
                await Enumerable.Range(0, 10).Select(x => CreateEnemyAsync(token));
            }
    
            /// <summary>
            /// 적을 생성한다
            /// </summary>
            private async UniTask CreateEnemyAsync(CancellationToken token)
            {
                var enemy = Instantiate(_enemyPrefab);
                // 초기화가 끝날 때까지 기다리다
                await enemy.InitializedAsync;
                // 초기화 중에 취소되면 중지
                token.ThrowIfCancellationRequested();
            }
        }
    }
    using System.Threading;
    using Cysharp.Threading.Tasks;
    using UnityEngine;
    using UnityEngine.AddressableAssets;
    using UnityEngine.ResourceManagement.AsyncOperations;
    
    namespace Samples3
    {
        /// <summary>
        /// 적
        /// </summary>
        public sealed class Enemy : MonoBehaviour
        {
            /// <summary>
            /// 초기화가 완료되었는지 여부
            /// </summary>
            public UniTask InitializedAsync => _initialTaskCompletionSource.Task;
            private readonly UniTaskCompletionSource _initialTaskCompletionSource = new UniTaskCompletionSource();
    
            private AsyncOperationHandle<GameObject> _operationHandle;
    
            /// <summary>
            /// 이 적 객체의 자식 요소 (비동기로 초기화됨)
            /// </summary>
            private GameObject _child;
    
            /// <summary>
            /// Start에서 초기화 처리가 실행됨
            /// </summary>
            private void Start()
            {
                var ct = this.GetCancellationTokenOnDestroy();
                InitializeAsync(ct).Forget();
            }
    
            /// <summary>
            /// 초기화 처리
            /// </summary>
            private async UniTaskVoid InitializeAsync(CancellationToken token)
            {
                _operationHandle = Addressables.InstantiateAsync("Child");
                await _operationHandle;
                _child = _operationHandle.Result;
            }
    
            private void OnDestroy()
            {
                Addressables.Release(_operationHandle);
    
                // Destroy가 먼저 실행되 취소합니다.
                _initialTaskCompletionSource.TrySetCanceled();
            }
        }
    }

    H. 컬렉션의 변동을 통지

    컬렉션 Array과 Dictionary같은 여러 데이터를 처리하는 객체를 가리킵니다.

    UniRx로 컬렉션 변동 모니터링

    ReactiveCollectionUniRx 에는 약간 ReactiveDictionary의 객체가 있습니다.

    • ReactiveCollection
    • ReactiveDictionary

    이러한 객체를 이용하는 것으로, “배열이나 맵의 내용이 변동했다”라는 것을 즉시 감지해 이벤트 처리를 실행하는 것이 가능해집니다.

    보충:ObservableCollections

    UniRx ReactiveCollection에 가까운 기능을 제공하는 라이브러리 ObservableCollections가 있습니다.

    이곳은 UniRx에 의존하지 않고, 다양한 컬렉션의 감시를 할 수 있게 되는 라이브러리가 되고 있습니다. (UniRx에는 존재하지 않는, ObservableHashSet, ObservableRingBuffer 있습니다)

    “UniRx를 도입하고 싶지 않다” “UniRx가 제공하는 ReactiveCollection 것보다 더 심도 있는 처리를 하고 싶다”라고 하는 경우는 이쪽의 도입도 검토하면 좋을 것입니다.

    I. (이하, 생각하면 가필합니다)

    요약

    • “UniRx 전용”해서 사용해야 되는 시대는 사라졌습니다.
      • 현대는 UniRx 이상에 편리한 라이브러리와 언어 기능이 갖추어져 있습니다.
      • 특히 async/await+UniTask는 매우 강력하며 UniRx 대신 사용할 수 있는 패턴이 많습니다.
    • 그러나 한편으로 UniRx의 용도가 완전히 없어진 것은 아니다.
      • 단순한 Observer 패턴의 구현 라이브러리로 사용해도 편리
      • ReactiveProperty는 특히 편리성이 높고 현역에서 사용할 수 있습니다.
    • UniRx를 너무 과신하지 말자
      • 사용 장소의 판별은 중요
      • async/await+UniTask쪽이 깨끗하게 쓸 수 있는 패턴도 있다
      • (async/await 모른다면 코루틴을 사용할 수 있습니다)