2022. 4. 3. 21:15ㆍGame Development/Unity
게임에는 수많은 이벤트들이 있다.
플레이어가 적 캐릭터의 시야에 언제 들어오고 나가는지, 체력이 언제 떨어지는지, 무기가 언제 바닥나는지, 적 캐릭터가 언제 위험한 바닥에 올라서는지 등, 우리가 게임할 때마다 자연스럽게 경험했던 것들이다.
키보드 입력이나 마우스 클릭, 오브젝트의 충돌체 영역 통과, 플레이어가 공격받는 것 등과 같은 순간처럼 이벤트는 수동적인 성격을 가지고 있으며, 스스로 능동적으로 발생하진 않는다.
이벤트는 응답을 만들고 발생시키며, 그 응답이 다시 그 이후의 응답을 일으키는 이벤트가 될 수 있다. 즉, 이벤트는 하나의 행동을 취함(이벤트)으로써 다른 행동을 수반(응답)하는 식으로, 서로의 행동들은 중요한 관계를 맺고 있다고 볼 수 있다.
프로퍼티(Property)를 통한 이벤트 구현의 문제점
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class EnemyObject : MonoBehaviour
{
private int _health = 100;
private int _ammo = 50;
public int Health
{
get { return _health; }
set
{
_health = Mathf.Clamp(value, 0, 100);
if(_health < 0)
{
OnDead();
return;
}
if(_health < 20)
{
OnHealthLow();
return;
}
}
}
public int Ammo
{
get { return _ammo; }
set
{
_ammo = Mathf.Clamp(value, 0, 50);
if(_ammo <= 0)
{
OnAmmoExpired();
return;
}
}
}
void OnDead()
{
// 죽는 이벤트 발생
}
void OnHealthLow()
{
// 체력 낮음 경고 알림 이벤트 발생
}
void OnAmmoExpired()
{
// 탄약이 없다는 이벤트 발생
}
}
- 값 수정 시점에 발생해야 할 이벤트를 체크하고, 조건이 만족한다면 이벤트를 발생시킨다.
- 매번 Update() 함수에서 체크하지 않기 때문에 성능 향상과 청결한 코드 둘 다 잡을 수 있다.
- 이벤트가 발생해야할 대상에 대한 정보가 없기 때문에 여러 오브젝트가 있다면 문제가 발생할 수 있다.
- 가령, 몬스터 100마리가 있는데 그 중 한 마리가 죽었다면 OnDead() 이벤트가 발생할 것이다.
- 100마리 중 어떤 몬스터가 죽었는지에 대한 정보가 없기 때문에 죽은 몬스터에 대한 정보를 알 수가 없다.
이런 문제 때문에 각 오브젝트가 모든 종류의 이벤트에 대해 선택적으로 수신하도록 하게 해야 할 필요성이 생겼다.
이벤트 관리 (Event Management)
위에서 어떤 문제가 있는지를 알았으니, 그것을 해결하기 위한 구조를 만들어야 한다. 책에 나와있는대로, EventManager 클래스를 통해 오브젝트가 특정 이벤트를 수신할 수 있도록 만들어 볼 것이다. 먼저 세 가지 중요한 개념이 있다.
이벤트 리스너 (Event Listener)
- 자기 자신이 발생시킨 이벤트를 포함하여, 어떤 이벤트가 발생하면 알기를 원하는 오브젝트
- 실제로 대부분의 오브젝트들은 하나 이상의 이벤트에 대한 리스너
- 예를 들어, 적의 경우엔 다른 적에 비해 적은 체력이나 탄약을 가진 경우에 대한 알림을 받길 원할 것
- 이 경우, 최소 두 가지 별개의 이벤트에 대한 리스너가 됨
이벤트 포스터 (Event Poster)
- 오브젝트가 이벤트 발생을 알아차린 경우, 다른 모든 리스너 오브젝트들이 알 수 있게 이벤트에 대해 알려야 함
- 위에 프로퍼티에서 발생시킨 내부 이벤트 호출과 달리, 전역 레벨에서 이벤트를 발생시켜야 함
이벤트 매니저 (Event Manager)
- 여러 레벨(Scene)에 걸쳐 계속 유지되고, 전역적으로 접근 가능한 싱글톤 오브젝트(Singleton Object)
- 리스너를 포스터에게 실질적으로 연결하는 역할
- 이벤트 매니저는 포스터가 보낸 알림을 받고, 적합한 모든 리스너에게 이벤트 형식으로 즉시 알림을 발생시킴
세 가지 개념에 대해 알아봤는데, 이것들을 효과적으로 구현할 수 있는 방법을 두 가지 정도 알아보자.
인터페이스를 통한 이벤트 관리
이벤트 리스너 만들기
이벤트가 발생했다면, 해당 이벤트를 수신할 수 있는 오브젝트로 우선 만들어줘야 한다. 인터페이스를 통해 리스너를 만드는 예제를 봤다.
public enum EVENT_TYPE
{
eGameInit,
eGameEnd,
eAmmoChange,
eHealthChange,
eDead
};
public interface IListener
{
// 이벤트가 발생할 때, 리스너에서 호출할 함수
void OnEvent(EVENT_TYPE EventType, Component Sender, object Param = null);
}
이벤트 타입에 해당하는 정보를 열거형(enum)으로 정의를 했고, 인터페이스 또한 정의를 해줬다.
OnEvent() 함수는 해당 인터페이스를 상속받은 클래스에서 각자의 이벤트 내용으로 다형성을 이루어줄 것이다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// IListener 인터페이스를 상속하여 만든 리스너
public class MyCustomListener : MonoBehaviour, IListener
{
// 이벤트 수신을 위해 함수 구현
public void OnEvent(EVENT_TYPE EventType, Component Sender, object Param = null)
{
}
}
이벤트 매니저에게 등록된 리스너들이 OnEvent() 함수를 호출당하는 구조로 만들 것이므로, 이제 이벤트 매니저를 만들어줘야 한다.
이벤트 매니저 만들기
이벤트 매니저의 역할은 이벤트가 실제로 발생했을 때, 등록된 리스너들에게 이벤트를 호출하는 것이다.
여러 씬(Scene)들을 넘나 들어도 존재해야 하고, 전역적으로 접근이 가능해야 하므로 싱글톤 패턴으로 구현한다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class EventManager : MonoBehaviour
{
public static EventManager Instance { get { return _instance; } }
private static EventManager _instance = null;
private Dictionary<EVENT_TYPE, List<IListener>> Listeners =
new Dictionary<EVENT_TYPE, List<IListener>>();
void Awake()
{
if(_instance == null)
{
_instance = this;
DontDestroyOnLoad(gameObject);
return;
}
DestroyImmediate(gameObject);
}
...
}
딕셔너리(Dictionary)로 선언한 Listeners는 이벤트를 수신받길 원하는 리스너들을 저장하고 있다.
예를 들어, 체력이 변경됐다는 이벤트를 수신받고 싶은 리스너는 한 둘이 아닐 수도 있다.
자신의 체력 상태를 나타내야 하는 UI 체력바는 물론 스탯(Stat)을 보여주는 창 또한 업데이트가 이루어져야 한다.
그렇기 때문에, 해당 이벤트에 대해 수신받을 리스너들을 리스트(List)로 관리하는 것이다.
리스너들은 이벤트 발생 알림을 받기 위해, 먼저 이벤트 매니저에 자신을 등록해야 하는 절차를 거쳐야 한다.
등록을 했다면, 이제 리스너들은 이벤트 매니저로부터 이벤트 발생 알림을 받을 수 있다.
그걸 위해 만든 AddListener() 메서드가 다음과 같다.
public void AddListener(EVENT_TYPE eventType, IListener Listener)
{
List<IListener> ListenList = null;
/* 이벤트 형식 키가 존재하는지 검사. 존재하면 리스트에 추가 */
if(Listeners.TryGetValue(eventType, out ListenList))
{
ListenList.Add(Listener);
return;
}
/* 없으면 새로운 리스트 생성 */
ListenList = new List<IListener>();
ListenList.Add(Listener);
Listeners.Add(eventType, ListenList); /* 리스너 리스트에 추가 */
}
이제, 이벤트가 발생했을 때 이벤트 매니저에게 소식을 알려줄 PostNotification() 메소드를 만들어야 한다.
public void PostNotification(EVENT_TYPE eventType, Component Sender, object param = null)
{
List<IListener> ListenList = null;
if (!Listeners.TryGetValue(eventType, out ListenList))
return;
for(int i = 0; i < ListenList.Count; i++)
ListenList?[i].OnEvent(eventType, Sender, param);
}
더 이상 사용하지 않는 이벤트가 있다면, 지우는 기능 또한 필요할 것이다.
public void RemoveEvent(EVENT_TYPE eventType) => Listeners.Remove(eventType);
제대로 된 리스너들을 가지고 있는지 검사하여, 무결성을 이뤄주도록 하는 기능도 필요할 것이다.
이벤트 매니저를 싱글톤으로 구현했기 때문에 씬이 바뀌어도 파괴되지 않고 그대로 유지되지만, 일반 오브젝트들은 그렇지 않을 수 있다.
씬이 바뀌어서 이미 파괴된 오브젝트를 참조하려고 하면 안 되므로, 그런 부분을 수정해주는 기능이 필요하다.
public void RemoveRedundancies()
{
Dictionary<EVENT_TYPE, List<IListener>> newListeners =
new Dictionary<EVENT_TYPE,List<IListener>>();
foreach(KeyValuePair<EVENT_TYPE, List<IListener>> Item in Listeners)
{
for (int i = Item.Value.Count - 1; i >= 0; i--)
{
if(Item.Value[i].Equals(null))
Item.Value.RemoveAt(i);
}
if(Item.Value.Count > 0)
newListeners.Add(Item.Key, Item.Value);
}
Listeners = newListeners;
}
OnLevelWasLoaded() 함수는 씬이 바뀌었을 때 호출되는 함수다. 여기에서 위 메소드를 호출해주자.
void OnLevelWasLoaded()
{
RemoveRedundancies();
}
테스트 해보기
이제 구조는 다 만들었다. 제대로 잘 동작하는지 테스트를 해보자.
간단하게 F 키를 누를 때마다 플레이어의 체력을 10씩 깎을 것이고, 이벤트를 발생시켜서 화면 UI Text에 변경된 체력 값으로 업데이트 되도록 만들어 봤다.
EnemyObject.cs
// 캐릭터 오브젝트를 하나 만들어서 부착
public class EnemyObject : MonoBehaviour
{
private int _health = 100;
public int Health
{
get { return _health; }
set
{
_health = Mathf.Clamp(value, 0, 100);
EventManager.Instance.PostNotification(EVENT_TYPE.eHealthChange, this, _health);
// 체력변경 이벤트를 이벤트 매니저에게 알리기
}
}
void Update()
{
if (Input.GetKeyDown(KeyCode.F))
{
Health -= 10;
}
}
}
UI_Health.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
// Canvas - Text 계층구조로, 이 스크립트는 Canvas에 부착됨
public class UI_Health : MonoBehaviour, IListener
{
private Text healthText;
void Start()
{
healthText = GetComponentInChildren<Text>();
EventManager.Instance.AddListener(EVENT_TYPE.eHealthChange, this);
healthText.text = "???";
}
public void OnEvent(EVENT_TYPE EventType, Component Sender, object Param = null)
{
switch (EventType)
{
case EVENT_TYPE.eHealthChange:
healthText.text = Param.ToString();
break;
}
}
}
허접하지만 간단한 테스트용으로 만들어 봤다. 체력변경 이벤트를 수신받고 싶은 쪽은 UI Text이기 때문에 이벤트 매니저에 리스너로 등록을 초기에 해준 모습이다. 그리고 이벤트가 발생하여 수신을 받을 때, 원하는 이벤트 타입만 수신하도록 switch문으로 분기해줬다.
유니티 튜토리얼에 나오는 귀여운 존 레몬으로 캐릭터를 선정해줬다.
대리자(Delegate)를 이용하여 만드는 방법
인터페이스 말고도, 대리자(Delegate)를 이용해서 만들 수도 있다. 인터페이스 구현 코드에서 약간만 수정하면 된다.
아래 더보기란에 코드를 첨부하겠다.
EventManager.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class EventManager : MonoBehaviour
{
public static EventManager Instance { get { return _instance; } }
private static EventManager _instance = null;
// 대리자 선언
public delegate void OnEvent(EVENT_TYPE eventType, Component Sender, object Param = null);
private Dictionary<EVENT_TYPE, List<OnEvent>> Listeners = new Dictionary<EVENT_TYPE, List<OnEvent>>();
void Awake()
{
if(_instance == null)
{
_instance = this;
DontDestroyOnLoad(gameObject);
return;
}
DestroyImmediate(gameObject);
}
public void AddListener(EVENT_TYPE eventType, OnEvent Listener)
{
List<OnEvent> ListenList = null;
if(Listeners.TryGetValue(eventType, out ListenList))
{
ListenList.Add(Listener);
return;
}
ListenList = new List<OnEvent>();
ListenList.Add(Listener);
Listeners.Add(eventType, ListenList);
}
public void PostNotification(EVENT_TYPE eventType, Component Sender, object param = null)
{
List<OnEvent> ListenList = null;
if (!Listeners.TryGetValue(eventType, out ListenList))
return;
for(int i = 0; i < ListenList.Count; i++)
{
ListenList?[i](eventType, Sender, param);
}
}
public void RemoveEvent(EVENT_TYPE eventType) => Listeners.Remove(eventType);
public void RemoveRedundancies()
{
Dictionary<EVENT_TYPE, List<OnEvent>> newListeners = new Dictionary<EVENT_TYPE, List<OnEvent>>();
foreach(KeyValuePair<EVENT_TYPE, List<OnEvent>> Item in Listeners)
{
for (int i = Item.Value.Count - 1; i >= 0; i--)
{
if(Item.Value[i].Equals(null))
Item.Value.RemoveAt(i);
}
if(Item.Value.Count > 0)
newListeners.Add(Item.Key, Item.Value);
}
Listeners = newListeners;
}
void OnLevelWasLoaded()
{
RemoveRedundancies();
}
}
UI_Health.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class UI_Health : MonoBehaviour
{
private Text healthText;
void Start()
{
healthText = GetComponentInChildren<Text>();
EventManager.Instance.AddListener(EVENT_TYPE.eHealthChange, OnEvent);
healthText.text = "???";
}
public void OnEvent(EVENT_TYPE EventType, Component Sender, object Param = null)
{
switch (EventType)
{
case EVENT_TYPE.eHealthChange:
healthText.text = Param.ToString();
break;
}
}
}
- 이 글은 <유니티 C# 스크립팅 마스터하기> 책을 바탕으로 공부한 글입니다.
'Game Development > Unity' 카테고리의 다른 글
[Unity] 시각적 디버깅에 용이한 기즈모(Gizmos) (0) | 2022.03.11 |
---|---|
[Unity] 전처리기 플래그 (Preprocessor Flag)를 이용한 조건 컴파일 코드 작성하기 (0) | 2022.03.08 |
[Unity] SendMessage와 BroadcastMessage (5) | 2022.02.22 |
[Unity] "Universal RP Light 2D"를 스크립트 제어하기 (0) | 2022.02.20 |
[Unity] 일방향 충돌, 측면 마찰 및 반발력을 설정할 수 있는 Platform Effector 2D (0) | 2022.02.19 |