Observer Pattern
옵저버 패턴은 변화를 감지해 이를 전파하는 패턴이다.
변화를 감지한 후, 이를 실제로 처리하는 로직을 분리할 수 있기 때문에 유용하다.
게임은 특히 이벤트 발생이 예측이 안 되는 분야이기 때문에 이러한 패턴이 적용된 부분이 많다.
간단한 예를 들어 보자.
게임상 캐릭터의 체력을 나타내는 UI가 있다고 가정해 보자.
캐릭터가 피해를 입어 체력이 닳면 UI가 업데이트되어야 한다.
하지만, 캐릭터가 언제 피해를 입을지는 예상할 수 없다.
따라서, 피해를 입는 이벤트가 발생했을 때 해당 이벤트의 발생 여부가 필요한 객체들에게 이를 전파하는 방식으로 해결할 수 있다.
다음은 옵저버 패턴을 이용하여 입력 시스템을 구현한 것이다.
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
public class IputManager : Singleton<IputManager>
{
public PlayerInput _playerInput;
public InputAction Number_1;
public InputAction Number_2;
public InputAction moveAction;
public InputAction jumpAction;
private void Start()
{
_playerInput = GetComponent<PlayerInput>();
Number_1 = _playerInput.actions["Number_1"];
Number_2 = _playerInput.actions["Number_2"];
moveAction = _playerInput.actions["Move"];
jumpAction = _playerInput.actions["Jump"];
}
private void Update()
{
var playerCharacterController = PlayerCharacterController.Instance;
playerCharacterController.OnReadValue("Move", moveAction.ReadValue<Vector2>());
if(jumpAction.triggered) playerCharacterController.OnTriggered("Jump");
if(Number_1.triggered) playerCharacterController.OnTriggered("Number_1");
if(Number_2.triggered) playerCharacterController.OnTriggered("Number_2");
}
public override void OnAwake()
{
}
}
ImputManager에서는 PlayerInput에서 바인딩된 입력을 감지하여 입력이 발생됐을 때, 이를 전파한다.
나아가, PlayerCharacterController에서는 이벤트가 발생했을 때 다음과 같이 처리한다.
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
public class PlayerCharacterController : Singleton<PlayerCharacterController>
{
private Dictionary<string, GameObject> players = new Dictionary<string, GameObject>();
private Dictionary<GameObject, UnityEvent<string>> TriggerActions = new Dictionary<GameObject, UnityEvent<string>>();
private Dictionary<GameObject, UnityEvent<string, Vector2>> ReadValueActions = new Dictionary<GameObject, UnityEvent<string, Vector2>>();
private GameObject currentPlayer;
public void AddObserver(string key, GameObject obj)
{
players.Add(key, obj);
if (players.Count == 1) ChangePlayer(key);
}
public void AddTriggerAction(GameObject obj, UnityAction<string> action)
{
if (!TriggerActions.ContainsKey(obj))
{
TriggerActions[obj] = new UnityEvent<string>();
}
TriggerActions[obj].AddListener(action);
}
public void AddReadValueAction(GameObject obj, UnityAction<string, Vector2> action)
{
if (!ReadValueActions.ContainsKey(obj))
{
ReadValueActions[obj] = new UnityEvent<string, Vector2>();
}
ReadValueActions[obj].AddListener(action);
}
private bool ChangePlayer(string key)
{
players.TryGetValue(key, out var player);
if (!player) return false;
currentPlayer = player;
return true;
}
public void OnTriggered(string key)
{
if (ChangePlayer(key)) return;
TriggerActions[currentPlayer].Invoke(key);
}
public void OnReadValue(string key, Vector2 value)
{
ReadValueActions[currentPlayer].Invoke(key, value);
}
public override void OnAwake()
{
}
}
UnityEvent를 이용하여 바인딩된 함수를 실행하도록 요청한다.
단, 입력이 필요한 스테이트에서 모두 바인딩을 진행했기 때문에 현재 실행 중인 스테이트에서만 반응할 수 있도록 스테이트에 활성화 여부를 판단하는 변수를 추가해줘야 한다.
using System;
using UnityEngine;
using UnityEngine.InputSystem;
public class IdleState : MonoBehaviour, IState, IReceiveInput
{
public StateMachine FSM { get; set; }
private BBCharacterMove BB { get; set; }
private bool bActive = false;
private bool isJumpTriggered = false;
private Vector2 moveValue;
public void InitState(BaseBB blackBoard)
{
BB = blackBoard as BBCharacterMove;
PlayerCharacterController.Instance.AddTriggerAction(gameObject, OnTriggered);
PlayerCharacterController.Instance.AddReadValueAction(gameObject, OnReadValue);
}
public void EnterState()
{
BB.animator.CrossFade("Idle", 0.1f);
BB.animator.SetFloat(BB.Speed, 0.0f);
bActive = true;
}
public void UpdateState(float deltaTime)
{
if (isJumpTriggered && BB.rb.velocity.y == 0)
{
FSM.ChangeState<JumpState>();
return;
}
if (0 < moveValue.sqrMagnitude)
{
FSM.ChangeState<WalkState>();
}
}
public void ExitState()
{
isJumpTriggered = false;
moveValue = Vector2.zero;
bActive = false;
}
public void OnTriggered(string key)
{
if (!bActive) return;
if (key == "Jump") isJumpTriggered = true;
}
public void OnReadValue(string key, Vector2 value)
{
if (key == "Move") moveValue = value;
}
}
State 자동 생성
State가 많아지면 하나씩 생성하고 타입을 검사하는 것이 쉽지 않을 수 있다.
따라서, Attribute를 이용하여 State라는 Attribute를 가진 클래스를 추출하여 Enum으로 자동생성한 후, 타입을 반환하는 헬퍼 클래스를 만들어 보자.
또한, 이를 간단하게 처리할 수 있도록 에디터를 수정해 window를 추가해 보자.
우선, Attribute를 사용하는 방법은 다음과 같다.
using System;
[AttributeUsage(AttributeTargets.Class)]
public class StateAttribute : Attribute
{
public string StateName;
public StateAttribute(string stateName)
{
StateName = stateName;
}
}
우선, Attribute를 상속받아 정의한다.
지금의 경우, 간단하게 이름만 있으면 된다.
이를 사용하기 위해서는 클래스 선언위에 []를 통해 State라는 Attribute를 사용하겠다고 명시한다.
[State("IdleState")]
public class IdleState : MonoBehaviour, IState, IReceiveInput
...
이후, 다음과 같은 로직을 통해 StateAttribute를 사진 클래스들의 타입을 추출한 후 enum에 저장할 이름들을 저장한다.
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
foreach (var assembly in assemblies)
{
var types = assembly.GetTypes()
.Where(t => t.GetCustomAttributes(typeof(StateAttribute), true).Length > 0);
foreach (var type in types)
{
var attr = type.GetCustomAttribute<StateAttribute>();
if (attr != null && !string.IsNullOrEmpty(attr.StateName))
{
if (!enums.ContainsKey(attr.StateName))
{
enums.Add(attr.StateName, new List<Type>());
}
enums[attr.StateName].Add(type);
}
}
}
var List = enums.Keys.OrderBy(s => s).ToList();
그리고 이를 파일 입출력을 통해 파일을 생성하면 된다.
var List = enums.Keys.OrderBy(s => s).ToList();
StringBuilder sb = new StringBuilder();
if (!Directory.Exists(savePath))
{
Directory.CreateDirectory(savePath);
}
sb.AppendLine("using System;");
sb.AppendLine("using System.Collections.Generic;");
sb.AppendLine($"public class StateTypesClasses");
sb.AppendLine("{");
GenerateFillEnumText(sb, List);
sb.AppendLine("\tprivate static readonly Dictionary<Type, StateTypes> TypeToState = new()");
sb.AppendLine("\t{");
foreach (var se in List)
{
var orderedList = enums[se].OrderBy(t => t.Name).ToList();
foreach (var type in orderedList)
{
sb.AppendLine($"\t\t[typeof({type})] = StateTypes.{se},");
}
}
sb.AppendLine("\t};");
sb.AppendLine("\tpublic static StateTypes GetState<T>() => GetState(typeof(T));");
sb.AppendLine("\tpublic static StateTypes GetState(Type type)");
sb.AppendLine("\t{");
sb.AppendLine("\t\treturn TypeToState.GetValueOrDefault(type, StateTypes.None);");
sb.AppendLine("\t}");
sb.AppendLine("}");
string filePath = Path.Combine(savePath, "StateTypesClasses.cs");
File.WriteAllText(filePath, sb.ToString());
이 작업은 만들고 싶은 스크립트를 Line별로 적은 것이다.
원하는 기능을 마음대로 추가해도 괜찮다.
그럼, 이런 기능이 동작할 수 있도록 에디터 윈도우를 추가해 보자.
private void OnGUI()
{
var dropArea = GUILayoutUtility.GetRect(100, 60);
savePath = GUI.TextField(dropArea, savePath);
var evt = Event.current;
switch (evt.type)
{
case EventType.DragUpdated:
if (dropArea.Contains(evt.mousePosition))
{
DragAndDrop.AcceptDrag();
DragAndDrop.visualMode = DragAndDropVisualMode.Copy;
}
Event.current.Use();
break;
case EventType.DragPerform:
if (!dropArea.Contains(evt.mousePosition)) break;
DragAndDrop.visualMode = DragAndDropVisualMode.Copy;
DragAndDrop.AcceptDrag();
if (DragAndDrop.paths.Length > 0) savePath = DragAndDrop.paths[0];
Event.current.Use();
break;
}
if (GUILayout.Button("Generate"))
{
Dictionary<string, List<Type>> enums = new();
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
foreach (var assembly in assemblies)
{
var types = assembly.GetTypes()
.Where(t => t.GetCustomAttributes(typeof(StateAttribute), true).Length > 0);
foreach (var type in types)
{
var attr = type.GetCustomAttribute<StateAttribute>();
if (attr != null && !string.IsNullOrEmpty(attr.StateName))
{
if (!enums.ContainsKey(attr.StateName))
{
enums.Add(attr.StateName, new List<Type>());
}
enums[attr.StateName].Add(type);
}
}
}
var List = enums.Keys.OrderBy(s => s).ToList();
StringBuilder sb = new StringBuilder();
if (!Directory.Exists(savePath))
{
Directory.CreateDirectory(savePath);
}
sb.AppendLine("using System;");
sb.AppendLine("using System.Collections.Generic;");
sb.AppendLine($"public class StateTypesClasses");
sb.AppendLine("{");
GenerateFillEnumText(sb, List);
sb.AppendLine("\tprivate static readonly Dictionary<Type, StateTypes> TypeToState = new()");
sb.AppendLine("\t{");
foreach (var se in List)
{
var orderedList = enums[se].OrderBy(t => t.Name).ToList();
foreach (var type in orderedList)
{
sb.AppendLine($"\t\t[typeof({type})] = StateTypes.{se},");
}
}
sb.AppendLine("\t};");
sb.AppendLine("\tpublic static StateTypes GetState<T>() => GetState(typeof(T));");
sb.AppendLine("\tpublic static StateTypes GetState(Type type)");
sb.AppendLine("\t{");
sb.AppendLine("\t\treturn TypeToState.GetValueOrDefault(type, StateTypes.None);");
sb.AppendLine("\t}");
sb.AppendLine("}");
string filePath = Path.Combine(savePath, "StateTypesClasses.cs");
File.WriteAllText(filePath, sb.ToString());
}
}
파일을 저장할 위치를 적을 수 있는 TextFeild를 추가했다.
편의를 위해 드래그앤 드롭 기능을 추가했다.
Generate버튼을 누르면 설정한 폴더에 파일이 생성된다.
UniTask
코루틴은 유니티에서 제공하는 비동기처럼 동작하는 기능이다.
하지만, 여러가지 문제가 있다.
우선, 코루틴에서는 반환값을 반환하기 쉽지 않다.
또한, 코루틴에서 wait을 진행하게 된다면 new를 통해 새로운 메모리를 할당하기 때문에 GC에서 처리할 쓰레기 메모리들이 생길 수 있다.
즉, 성능상 그리 좋지 않다.
이러한 문제를 개선하는 것이 UniTask이다.
UniTask는 코루틴과 달리 실행될 때마다 메모리 할당이 일어나지 않는다.
따라서, GC에서 따로 처리를 하지 않아도 된다.
하지만, 그렇기 때문에 만약 UniTask가 종료되지 않은 상태로 오브젝트가 소멸한다면 문제가 발생할 수 있다.
이러한 상황을 방지하기 위해 다음과 같은 방법을 사용해도 된다.
async UniTask Example(GameObject obj)
{
// CancellationToken 생성
var cancellationToken = obj.GetCancellationTokenOnDestroy();
try
{
await UniTask.Delay(2000, cancellationToken: cancellationToken);
Debug.Log("오브젝트가 삭제되지 않음: " + obj.name);
}
catch (OperationCanceledException)
{
Debug.Log("작업이 오브젝트 삭제로 취소됨");
}
}
GetCancellationTokenOnDestroy라는 함수를 통해 취소 토큰을 생성한 뒤, UniTask를 실행할 때, 토큰으로 전달한다.
이렇게 되면 UniTask가 실행하는 도중 오브젝트가 소멸한다면 catch문에서 예외를 처리한다.
UniTask는 Delay이외에도 UniTask.WaitUntil, UniTask.WaitWhile, UniTask.Yield 등 유용한 함수를 제공한다.