보관함

게임 프로그래밍 패턴 Part 2 디자인 패턴 다시 보기 - 관찰자

niamdank 2020. 2. 1. 15:37
게임 프로그래밍 패턴로버트 나이스트롬 (Robert Nystrom)
상세보기

이 시리즈는 [게임 프로그래밍 패턴]에 등장하는 팁을 정리하고 패턴을 직접 구현하거나 구현되어 있는 패턴을 확인하는 것으로 해당 패턴에 대해 이해하는 것을 목표로 한다.

 

이번 포스팅에서는 Part 2의 세 번째 패턴인 관찰자 패턴을 살펴보고 유니티로 관찰자 패턴을 이용한 업적 시스템을 만드는 것을 목표로 한다.


관찰자 패턴이란?)

[GoF디자인 패턴]에서는 관찰자 패턴을 다음과 같이 소개했다.

객체 사이에 일 대 다의 의존 관계를 정의해두어, 어떤 객체의 상태가 변할 때 그 객체에 의존성을 가진 다른 객체들이 그 변화를 통지받고 자동으로 업데이트될 수 있게 만듭니다.

 

이것을 다시 말하면 어떤 기능을 담당하는 시스템을 객체가 구독(Register) 한 상태에서 객체에 변화가 생길 때 시스템에 알리고(Notify) 시스템에 의미 있는 변화인 경우 특정 기능을 수행하도록 하는 것이다.

 

정리하면, 관찰자 패턴은 어떤 코드에서 흥미로운 일이 생겼을 때 누가 받든 상관없이 알림을 보낼 수 있다.

 

구현 준비 및 목표)

유티니 2019를 사용하여 튜토리얼 프로젝트 중 2D Roguelike를 구현한다.

Unity Learn - 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를 추가한다.

ahievementText라는 이름으로 Text추가, 사이즈 28, 색 흰색(255,255,255)

 

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로 만들어 다시 사용할 때 찾아 쓰도록했다.

 

실행 결과는 다음과 같다.

 

추가정보)

  • 관찰자 패턴은 동기적이므로 멀티스레드를 많이 사용하는 경우 이벤트 큐를 이용하는 것이 더 좋을 수 있다.
  • 만약 관찰자간 순서 때문에 문제가 생긴다면 이는 관찰자 사이에 커플링이 존재한다는 의미이므로 나중에 문제가 될 소지가 크다.
  • 객체가 제거될 때에는 구독하고 있던 관찰자를 반드시 해제해줘야 한다.
  • 관찰자를 구현하는 최신 방식은 메서드나 함수 레퍼런스만으로 만드는 것이다.