게임 개발 로그

유니티로 배우는 게임 디자인 패턴: Observer 패턴 (Delegate 추가하기) 본문

게임 디자인 패턴

유니티로 배우는 게임 디자인 패턴: Observer 패턴 (Delegate 추가하기)

03:00am 2024. 11. 30. 23:07
'유니티로 배우는 게임 디자인 패턴' 책을 베이스로 하여 코드 구조를 바꿔 봤습니다.

 

 

 

옵저버 패턴의 구조

 

옵저버 패턴이란?

  옵저버 패턴이란 한 오브젝트가 주체 역할이고, 다른 오브젝트들이 관찰자 역할을 하여 일대다 관계를 형성하는 패턴이다. 이벤트 버스 패턴과 비슷한 면이 있으나 이벤트버스는 다대다 관계라는 점에서 차이가 있다. 

 

 

 

옵저버 패턴의 장점

  • 역동성: 주체에 필요한 만큼의 객체를 관찰자로 추가할 수 있고, 동적으로 제거할 수도 있다.
  • 일대다: 객체 하나에 여러 이벤트를 처리할 수 있는 일대다 관계 이벤트 처리 시스템을 구현할 수 있다. 

 

 

옵저버 패턴의 단점

  • 무질서: 관찰자가 알람을 받는 순서를 보장하지 않는다. 관찰자가 이벤트를 받는 것에 순서가 있다면 기본 형태의 옵저버 패턴은 적합하지 않다.
  • 누수: 주체는 관찰자의 주소가 아닌 관찰자 자체를 참조하기 때문에 메모리 누수를 일으킬 가능성이 있다. 

 

상태를 자주 변경하고 변경사항에 대응해야 하는 종속성이 많은 핵심 컴포넌트가 있다면 옵저버 패턴을 사용한다.

 

 

 

책의 구현

아래 더보기 글은 '유니티로 배우는 게임 디자인 패턴' 책에 나오는 예제 코드이다. (해당 책의 깃허브에서도 볼 수 있다.)

 

Game-Development-Patterns-with-Unity-2021-Second-Edition/Assets/Chapters/Chapter09/Decoupling Components with the Observer patte

Game Development Patterns with Unity 2021 - Second Edition, published by Packt - PacktPublishing/Game-Development-Patterns-with-Unity-2021-Second-Edition

github.com

더보기

'유니티로 배우는 게임 디자인 패턴'에 나오는 코드 

1. Subject (주체) 추상 클래스

public abstract class Subject : MonoBehaviour
{
    private readonly 
        ArrayList _observers = new ArrayList();

    protected void Attach(Observer observer)
    {
        _observers.Add(observer);
    }

    protected void Detach(Observer observer)
    {
        _observers.Remove(observer);
    }

    protected void NotifyObservers()
    {
        foreach (Observer observer in _observers)
        {
            observer.Notify(this);
        }
    }
}

 

 

2. Observer (관찰자) 추상 클래스 

public abstract class Observer : MonoBehaviour
{
    public abstract void Notify(Subject subject);
}

 

 

3. BikeController : 주체가 될 오브젝트

public class BikeController : Subject
{
    public bool IsTurboOn
    {
        get; private set;
    }

    public float CurrentHealth
    {
        get { return health; } 

    }

    private bool _isEngineOn;
    private HUDController _hudController;
    private CameraController _cameraController;
    [SerializeField] private float health = 100.0f;

    void Awake()
    {
        _hudController = 
            gameObject.AddComponent<HUDController>();

        _cameraController = 
            (CameraController) 
            FindObjectOfType(typeof(CameraController));
    }

    private void Start()
    {
        StartEngine();
    }

    void OnEnable()
    {
        if (_hudController) 
            Attach(_hudController);

        if (_cameraController) 
            Attach(_cameraController);
    }

    void OnDisable()
    {
        if (_hudController) 
            Detach(_hudController);

        if (_cameraController) 
            Detach(_cameraController);
    }

    private void StartEngine()
    {
        _isEngineOn = true;

        NotifyObservers();
    }

    public void ToggleTurbo()
    {
        if (_isEngineOn) 
            IsTurboOn = !IsTurboOn;

        NotifyObservers();
    }

    public void TakeDamage(float amount)
    {
        health -= amount;
        IsTurboOn = false;

        NotifyObservers();

        if (health < 0)
            Destroy(gameObject);
    }
}

 

 

4. HUDController와 CameraController : 관찰자가 될 오브젝트

public class HUDController : Observer {
    private bool _isTurboOn;
    private float _currentHealth;
    private BikeController _bikeController;

    void OnGUI() {
        GUILayout.BeginArea (
            new Rect (50,50,100,200));

        GUILayout.BeginHorizontal ("box");
        GUILayout.Label ("Health: " + _currentHealth);
        GUILayout.EndHorizontal ();

        if (_isTurboOn) {
            GUILayout.BeginHorizontal("box");
            GUILayout.Label("Turbo Activated!");
            GUILayout.EndHorizontal();
        }

        if (_currentHealth <= 50.0f) {
            GUILayout.BeginHorizontal("box");
            GUILayout.Label("WARNING: Low Health");
            GUILayout.EndHorizontal();
        }

        GUILayout.EndArea ();
    }

    public override void Notify(Subject subject) {
        if (!_bikeController)
            _bikeController = 
                subject.GetComponent<BikeController>();

        if (_bikeController) {
            _isTurboOn = 
                _bikeController.IsTurboOn;

            _currentHealth = 
                _bikeController.CurrentHealth;
        }
    }
}

 

public class CameraController : Observer
{
    private bool _isTurboOn;
    private Vector3 _initialPosition;
    private float _shakeMagnitude = 0.1f;
    private BikeController _bikeController;

    void OnEnable()
    {
        _initialPosition = 
            gameObject.transform.localPosition;
    }

    void Update()
    {
        if (_isTurboOn)
        {
            gameObject.transform.localPosition =
                _initialPosition + 
                (Random.insideUnitSphere * _shakeMagnitude);
        }
        else
        {
            gameObject.transform.
                localPosition = _initialPosition;
        }
    }

    public override void Notify(Subject subject)
    {
        if (!_bikeController)
            _bikeController =
                subject.GetComponent<BikeController>();

        if (_bikeController)
            _isTurboOn = _bikeController.IsTurboOn;
    }
}

 

책의 예제로 나오는 코드는 길지만 간단한 행위를 구현하고 있다. BikeController가 Turbo 상태인지, 아닌지에 따라 카메라 컨트롤러는 진동하거나 멈추고 있고, HUD 컨트롤러는 UI로서 그 상태를 표시하고 있다. BikeController의 Turbo 상태가 On/Off 될 때마다 옵저버 패턴을 이용하여 이벤트 알림(Notify)를 보내고 있는 것이다.

 

 

 

활용

  책의 Observer 패턴 형태를 유지하면서도 상호 참조 부분을 지우고 싶었다. 기존 코드는 BikeController는 HUDController, CameraController를 참조하고 있고, HUD/Camera 컨트롤러는 BikeController를 참조하고 있다. 따라서 유니티의 Delegate와 Event를 활용하여 상호참조를 없애고자 했다. 대신 static 함수를 사용하게 되어 메모리를 조금 더 차지하게 된다. 

 

1. Subject

Subject는 책에 나오는 코드와 완전 동일하다. Subject 클래스는 추상화 클래스로, 이를 상속하는 클래스는 '주체'로 존재하게 된다. 다른 관찰자에게 이벤트를 전달하는 역할이다. 따라서 관찰자 리스트를 담는 ArrayList가 있고, Attach/Detach 등의 기능을 구현한다. 이벤트 발생시 NotifyObservers()를 호출하여 ArrayList 목록에 있는 모든 관찰자에게 알림(이벤트)을 전송한다.

public abstract class Subject : MonoBehaviour
{
	private readonly ArrayList _observers = new ArrayList();

	public void Attach(Observer observer)
	{
		_observers.Add(observer);
	}

	public void Detach(Observer observer)
	{
		_observers.Remove(observer);  
	}

	public void NotifyObservers()
	{
		foreach (Observer observer in _observers)
			observer.Notify(this);
	}
}

 

 

2. Observer

  책의 코드와 비슷하지만 AddObserver, RemoveObserver를 추가했다. AddObserver는 매개변수로 받게 되는 Subject의 List에  나를 관찰자로 추가하는 함수다. 또, RemoveObserver는 반대로 매개변수로 받게 되는 Subject의 List에 나라는 관찰자를 삭제하는 함수다. 

public abstract class Observer : MonoBehaviour
{
	public abstract void Notify(Subject subject);

    public void AddObserver(Subject subj)
    {
        subj.Attach(this);
    }

    public void RemoveObserver(Subject subj)
    {
        subj.Detach(this);
    }
}

 

 

3. BikeController

  BikeController에서 HUDController와 CameraController를 참조하고 있던 부분을 삭제했다. 대신 DelegateClass와 static event를 추가하여 다른 클래스에서 event에 접근/추가를 할 수 있도록 변경했다. 사실 delegate나 event, 혹은 action을 사용하면 옵저버 패턴을 구현할 수 있지만 이 책에서 제시하는 옵저버 패턴의 원형을 잃고 싶지 않아서 delegate와 event, 그리고 Subject/Observer 클래스까지 모두 활용하게 되었다. 

  BikeController에서는 Start()나 OnEnable()이 되면 AddObserver 이벤트를 호출하여 구독자들을 관찰자로 등록하게 된다. OnDisable()이 되면 RemoveObserver 이벤트를 호출하여 구독자들의 구독을 취소시킨다. 이 이벤트를 받고 싶은 구독자는 "직접" AddObserver나 RemoveObserver에 자신의 이벤트를 등록해야 한다. 그렇기에 위의 2번 Observer 클래스에서 AddObserver, RemoveObserver 함수를 미리 구현했다. 

public class BikeController : Subject
{
    private bool _isTurboOn;

    public bool IsTurboOn
    {
        get { return _isTurboOn; }
        private set { }
    }

    public float CurrentHealth
    {
        get { return health; }
    }

    private bool _isEngineOn;
    public delegate void DelegateClass<T>(Subject go);
    static public event DelegateClass<Subject> AddObserver;
    static public event DelegateClass<Subject> RemoveObserver;

    [SerializeField] 
    private float health = 100.0f;

    private void Start()
    {
        StartEngine();
        AddObserver(this);
    }

    public void TakeDamage(float amount)
    {
        health -= amount;
        IsTurboOn = false;

        NotifyObservers();

        if (health < 0)
            Destroy(gameObject);
    }

    private void StartEngine()
    {
        _isEngineOn = true;
        NotifyObservers();
    }

    public void ToggleTurbo()
    {
        if (_isEngineOn)
            _isTurboOn = !_isTurboOn;
        NotifyObservers();
        Debug.Log("Turbo Active: " + _isTurboOn.ToString());
    }

    private void OnEnable()
    {
        AddObserver(this);
    }

    private void OnDisable()
    {
        RemoveObserver(this);
    }
}

 

4. HUDController / CameraController

  HUDController와 CameraController에서는 BikeController의 AddObserver/RemoveObserver 이벤트에 자신의 함수를 등록하는 부분을 구현해야 한다. 그래야 BikeController로부터 오는 이벤트를 구독할 수 있다. 

public class HUDController : Observer
{
	private bool _isTurboOn;
	private float _currentHealth;
	private void Start()
    {
        BikeController.AddObserver -= AddObserver;
        BikeController.AddObserver += AddObserver;
        BikeController.RemoveObserver -= RemoveObserver;
        BikeController.RemoveObserver += RemoveObserver;
	}

	void OnGUI()
	{
		GUILayout.BeginArea(new Rect(50, 200, 100, 200));
		GUILayout.BeginHorizontal("box");
		GUILayout.Label("Health: " + _currentHealth);
		GUILayout.EndHorizontal();
		if (_isTurboOn)
		{
			GUILayout.BeginHorizontal("box");
			GUILayout.Label("Turbo Activated!");
			GUILayout.EndHorizontal();
		}
		if (_currentHealth <= 50.0f)
		{
			GUILayout.BeginHorizontal("box");
			GUILayout.Label("WARNING: Low Health!");
			GUILayout.EndHorizontal();
		}

		GUILayout.EndArea();
	}

	public override void Notify(Subject subject)
	{
		if (subject is BikeController bike)
		{
			_isTurboOn = bike.IsTurboOn;
			_currentHealth = bike.CurrentHealth;
		}
	}
}
public class CameraController : Observer
{
	private bool _isTurboOn;
	private Vector3 _initialPosition;
	private float _shakeMagnitude = 0.1f;

    private void Start()
    {
        BikeController.AddObserver -= AddObserver;
        BikeController.AddObserver += AddObserver;
        BikeController.RemoveObserver -= RemoveObserver;
        BikeController.RemoveObserver += RemoveObserver;
    }

	private void Update()
	{
		if (_isTurboOn)
			gameObject.transform.localPosition = _initialPosition + (Random.insideUnitSphere * _shakeMagnitude);
		else
			gameObject.transform.localPosition = _initialPosition;
	}

	public override void Notify(Subject subject)
	{
		if (subject is BikeController bike)
			_isTurboOn = bike.IsTurboOn;
	}
}

 

 

 

 

  Delegate와 event를 추가하여 상호참조를 없애긴 했으나 효율적인 코드인지 따져보았을 때는 그다지 좋은 코드는 아닌 듯하다. 또한 Observer에서 AddObserver, RemoveObserver를 따로 구현하여 넣어주는 방식이 그다지 가시성이 좋은 것 같지 않다. 이후에 봤을 때 구조가 헷갈릴 수 있을 것 같다. 완벽히 마음에 들지는 않지만 상호참조를 하는 것보다는 위의 방식이 좋을 것 같다는 생각이다.