유니티 설계 경험
유니티를 이용하면서 내가 경험한 내용을 정리한 글이다. 이 글은 유니티를 개발하는 과정 속에서 지속적으로 업데이트 될 예정이다.
핵심 개념
다음 개념들은 알고 시작하는 것이 좋다.
유니티 버전단위 문서
유니티 버전에 따른 매뉴얼이다. 이 문서를 통해 대부분의 유니티 관련한 이용방법을 숙지할 수 있다.
https://docs.unity3d.com/2021.2/Documentation/Manual/index.html
유니티 핵심 객체
유니티로 개발을 시작하기 전에 반드시 숙지해야하는 객체들에 대한 문서이다.
https://docs.unity3d.com/Manual/ScriptingImportantClasses.html
유니티 이벤트 플로우
유니티는 코어 시스템이 모두 C++로 개발되어있고 이 부분은 유니티 C# 개발자들에게는 감춰져있다. 그렇기 때문에 유니티에서 여러 코어 로직들이 실행되는 타이밍을 C# 에서 활용할 수 있는 인터페이스가 필요하다. 예를 들면, 모든 물리 로직이 끝난 직후, 모든 렌더링 작업이 끝난 직후 등등과 같은 타이밍들이 있을 것이다. 다음 링크는 이 이벤트를 정리해둔 문서이다.
https://docs.unity3d.com/Manual/ExecutionOrder.html
유니티 아키텍쳐
유니티에 전반적인 설계 구조에 대한 문서이다.
https://docs.unity3d.com/Manual/unity-architecture.html
설계 규칙
개인적으로 유니티를 사용하면서 정리한 설계 규칙이다.
유니티 C# 프로그래밍 컨벤션은 Microsoft C# 컨벤션을 이용한다.
https://docs.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions
프로젝트의 커스텀 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.html
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)
{
// ..
}
}
Event Convention
다음 문서를 참고한다.
https://algorfati.tistory.com/212
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 함수에서는 각 객체들 간의 상호참조를 위한 파라메터들을 공유하도록 하여 문제를 해결할 수 있다.
모듈 분리를 통한 종속성 축소
ㅇ
상태 관리와 FSM
게임 개발에 있어서 상태 관리는 피할 수 없는 중요한 개념이다. 상태 관리가 무엇인지 예시를 통해 알아보자.
스타크래프트의 저글링을 생각해보자. 저글링은 움직일 수 있고, 공격할 수 있고 가만히 있을 수 있다. 그리고 체력이 0이 되면 죽는다. 이처럼 저글링이라는 객체는 여러 가지 상태를 갖을 수 있고, 각 상태에 따라 처리해야하는 로직이 달라질 수 있다. 공격중일때는 공격 애니메이션을 출력시키면서 동시에 공격 딜레이를 계산해야할 것이다. 움직일 때는 이동 애니메이션을 출력시키면서 동시에 이동 속도 계산과 이동 위치 계산이 이루어져야 할 것이다. 이와 같이 하나의 객체가 여러 상태로써 표현되어야 하는 경우 유한상태기계 (Finate State Machine) 라는 개념을 활용하면 유용하다.
FSM은 요구사항이 간단한 경우 직접 개발하여 사용하기도 하지만, 개인적인 경험으론 추가적인 요구사항이 생기면서 가볍게 만들었던 FSM 코드를 버리고 처음부터 다시 만들어야 했었다. 현재는 C# FSM 오픈소스인 stateless를 이용하고 있다. 현대적인 설계철학을 잘 활용하는 동시에 다양한 요구사항을 충족시키면서 쓰기도 간편하다.
https://github.com/dotnet-state-machine/stateless.git
상태 관리를 이용하여 다음과 같은 작업을 할 수 있다.
https://algorfati.tistory.com/210
프로젝트 개발 및 원칙 적용
다음 프로젝트를 진행하며 일반적인 게임 프로젝트를 위한 설계 원칙들을 정리하고 있다.
Itch.io
https://insooneelife.itch.io/path-to-the-veil
Discord
기타 기능
단축키
https://docs.unity3d.com/kr/560/Manual/UnityHotkeys.html
오픈소스
개발 전 반드시 참고하는 오픈소스들이다.
유니티의 수많은 유용한 오픈소스들을 정리해놓은 레포지토리
https://github.com/michidk/Unity-Script-Collection
유니티의 심플한 예제들을 정리해놓은 레포지토리
https://github.com/UnityCommunity/UnityLibrary
#아직 리서치 필요
https://github.com/cmilr/Unity2D-Components
https://wonsorang.tistory.com/657
'게임 엔진 > Unity' 카테고리의 다른 글
[Unity] C# Event 컨벤션 정리 (2) | 2023.12.20 |
---|---|
[Google Play] 구글 플레이 결제 시스템(IAP) 세팅 (0) | 2021.08.08 |
[Google Play] 구글 플레이 게임 서비스 세팅 (2) | 2021.08.07 |
[Unity] [Example] 유니티에서 데이터 관리 방법(ScriptableObject) (0) | 2021.07.12 |
[Unity] [Example] 평면 연산 방법 (Plane) (0) | 2020.07.28 |