게임 프로그래밍 패턴 Part 2 디자인 패턴 다시 보기 - 관찰자
이 시리즈는 [게임 프로그래밍 패턴]에 등장하는 팁을 정리하고 패턴을 직접 구현하거나 구현되어 있는 패턴을 확인하는 것으로 해당 패턴에 대해 이해하는 것을 목표로 한다.
이번 포스팅에서는 Part 2의 세 번째 패턴인 관찰자 패턴을 살펴보고 유니티로 관찰자 패턴을 이용한 업적 시스템을 만드는 것을 목표로 한다.
관찰자 패턴이란?)
[GoF디자인 패턴]에서는 관찰자 패턴을 다음과 같이 소개했다.
객체 사이에 일 대 다의 의존 관계를 정의해두어, 어떤 객체의 상태가 변할 때 그 객체에 의존성을 가진 다른 객체들이 그 변화를 통지받고 자동으로 업데이트될 수 있게 만듭니다.
이것을 다시 말하면 어떤 기능을 담당하는 시스템을 객체가 구독(Register) 한 상태에서 객체에 변화가 생길 때 시스템에 알리고(Notify) 시스템에 의미 있는 변화인 경우 특정 기능을 수행하도록 하는 것이다.
정리하면, 관찰자 패턴은 어떤 코드에서 흥미로운 일이 생겼을 때 누가 받든 상관없이 알림을 보낼 수 있다.
구현 준비 및 목표)
유티니 2019를 사용하여 튜토리얼 프로젝트 중 2D Roguelike를 구현한다.

구현 목표는 다음과 같다.
1. 관찰자 패턴을 적용한 업적 매니저를 제작한다.
2. 업적 달성 시 화면에 알림을 표시하는 기능을 추가한다.
구현 과정)
| 업적 매니저 제작
업적을 달성하기 위한 조건을 업적매니저에 꾸준히 보내주고 받은 정보를 바탕으로 해결 가능한 부분을 확인해 업적을 해금하도록 스크립트를 추가한다.
using System.Collections;
using System.Collections.Generic;
using System;
using UnityEngine;
public class AcheivementManager : MonoBehaviour
{
public enum Achievements
{
Walk50,
ReachDay5,
}
class AchievementsComparer : IEqualityComparer<Achievements>
{
public bool Equals(Achievements a, Achievements b)
{
return a == b;
}
public int GetHashCode(Achievements obj)
{
return ((int)obj).GetHashCode();
}
}
static AcheivementManager _instance;
public static AcheivementManager Instance
{
get
{
if(_instance == null)
{
GameObject obj = new GameObject("AcheivementManager");
_instance = obj.AddComponent<AcheivementManager>();
DontDestroyOnLoad(obj);
}
return _instance;
}
}
Dictionary<Achievements, bool> _dicAchievementUnlock =
new Dictionary<Achievements, bool>(new AchievementsComparer());
public void OnNotify(Achievements achv, int level = 0, int totalWalk = 0)
{
switch(achv)
{
case Achievements.ReachDay5:
UnlockReachDay5(level);
break;
case Achievements.Walk50:
UnlockWalk50(totalWalk);
break;
}
}
public AcheivementManager()
{
foreach(Achievements achv in Enum.GetValues(typeof(Achievements)))
{
_dicAchievementUnlock[achv] = false;
}
}
void UnlockWalk50(int totalWalk)
{
if (_dicAchievementUnlock[Achievements.Walk50])
return;
if(totalWalk >= 50)
{
Debug.Log("50걸음 걷기 달성!");
_dicAchievementUnlock[Achievements.Walk50] = true;
}
}
void UnlockReachDay5(int level)
{
if (_dicAchievementUnlock[Achievements.ReachDay5])
return;
if (level >= 5)
{
Debug.Log("5일 버티기 성공!");
_dicAchievementUnlock[Achievements.ReachDay5] = true;
}
}
}
그리고 여기에서 추가한 조건을 만족시킬 수 있도록 다음과 같이 누적 걸음 수를 기록하는 변수를 추가하고 이동할 때 해당 값을 늘리고 체크하는 콜백을 추가했다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class InputManager : MonoBehaviour
{
// ... 중략
private int _totalWalk = 0;
private void Start()
{
if (instance == null)
{
instance = this;
}
else if (instance != this)
{
Destroy(gameObject);
return;
}
DontDestroyOnLoad(gameObject);
LeftButton += AddTotalWalk;
RightButton += AddTotalWalk;
UpButton += AddTotalWalk;
DownButton += AddTotalWalk;
}
// ... 중략
void AddTotalWalk()
{
_totalWalk++;
AcheivementManager.Instance.OnNotify(
AcheivementManager.Achievements.Walk100,
totalWalk: _totalWalk
);
}
}
또, 마찬가지로 게임의 레벨(Day)가 증가시켜주는 부분에 업적 매니저에 정보를 넘기는 코드를 추가한다.
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using UnityEngine.UI;
public class GameManager : MonoBehaviour
{
// ... 중략
private void OnLevelWasLoaded(int index)
{
level++;
AcheivementManager.Instance.OnNotify(
AcheivementManager.Achievements.ReachDay10,
level
);
InitGame();
enemiesMoving = false;
}
// ... 후략
}
추가적으로 Player는 명령 패턴에 넣는 콜백을 대입에서 추가로 수정하고 OnDestroy에서 다시 해당 콜백을 제거하도록 했다.
using UnityEngine;
using System.Collections;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
public class Player : MovingObject
{
// ... 중략
protected override void Start()
{
animator = GetComponent<Animator>();
food = GameManager.instance.playerFoodPoints;
foodText.text = $"Food : {food}";
InputManager.instance.LeftButton += MoveLeft;
InputManager.instance.RightButton += MoveRight;
InputManager.instance.UpButton += MoveUp;
InputManager.instance.DownButton += MoveDown;
base.Start();
}
private void OnDestroy()
{
InputManager.instance.LeftButton -= MoveLeft;
InputManager.instance.RightButton -= MoveRight;
InputManager.instance.UpButton -= MoveUp;
InputManager.instance.DownButton -= MoveDown;
}
// ... 후략
}
위 코드는 기존 2D Roguelike에 명령 패턴을 적용한 것으로 이전 포스팅(게임 프로그래밍 패턴 Part 2 디자인 패턴 다시 보기 - 명령)에서 확인할 수 있다.
처리 결과는 다음과 같다.
| 업적 달성 알림 기능 추가
달성 알림을 보여주기 위해 Text UI를 추가한다.

AchievementManager엔 다음 코드를 추가하여 도전과제 달성 시 UI로 표시되도록 한다.
using System.Collections;
using System.Collections.Generic;
using System;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
public class AcheivementManager : MonoBehaviour
{
// ... 중략
Text _achievementText;
Text achievementText
{
get
{
if(_achievementText == null)
{
GameObject obj = GameObject.Find("Canvas/achievementText");
if(obj != null)
{
_achievementText = obj.GetComponent<Text>();
}
}
return _achievementText;
}
}
// ... 중략
private void Start()
{
SceneManager.sceneLoaded += OnSceneLoad;
}
void OnSceneLoad(Scene scene, LoadSceneMode loadSceneMode)
{
_achievementText = null;
}
public void OnNotify(Achievements achv, int level = 0, int totalWalk = 0)
{
switch(achv)
{
case Achievements.ReachDay5:
UnlockReachDay5(level);
break;
case Achievements.Walk50:
UnlockWalk50(totalWalk);
break;
}
}
void UnlockWalk50(int totalWalk)
{
if (_dicAchievementUnlock[Achievements.Walk50])
return;
if(totalWalk >= 50)
{
_dicAchievementUnlock[Achievements.Walk50] = true;
StartCoroutine(Cor_ShowText5Sec("50걸음 걷기 달성!"));
}
}
void UnlockReachDay5(int level)
{
if (_dicAchievementUnlock[Achievements.ReachDay5])
return;
if (level >= 5)
{
_dicAchievementUnlock[Achievements.ReachDay5] = true;
StartCoroutine(Cor_ShowText5Sec("5일 버티기 성공!"));
}
}
IEnumerator Cor_ShowText5Sec(string text)
{
achievementText.text = text;
yield return new WaitForSeconds(5);
achievementText.text = string.Empty;
}
}
씬을 재 로드할때 기존에 찾아둔 UI가 제거되어 쓰레기 값을 가리키는 일이 발생하지 않도록 씬일 로딩됐을 때 해당 값을 null로 만들어 다시 사용할 때 찾아 쓰도록했다.
실행 결과는 다음과 같다.
추가정보)
- 관찰자 패턴은 동기적이므로 멀티스레드를 많이 사용하는 경우 이벤트 큐를 이용하는 것이 더 좋을 수 있다.
- 만약 관찰자간 순서 때문에 문제가 생긴다면 이는 관찰자 사이에 커플링이 존재한다는 의미이므로 나중에 문제가 될 소지가 크다.
- 객체가 제거될 때에는 구독하고 있던 관찰자를 반드시 해제해줘야 한다.
- 관찰자를 구현하는 최신 방식은 메서드나 함수 레퍼런스만으로 만드는 것이다.