게임 개발 로그

유니티로 배우는 게임 디자인 패턴: 방문자 패턴 본문

게임 디자인 패턴

유니티로 배우는 게임 디자인 패턴: 방문자 패턴

03:00am 2024. 12. 11. 16:24

 

 

방문자 패턴(Visitor Pattern)이란?

위키백과 정의
방문자 패턴은 객체 구조에서 분리시키는 디자인 패턴이다. 이렇게 분리를 하면 구조를 수정하지 않고도 실질적으로 새로운 동작을 기존의 객체 구조에 추가할 수 있게 된다. 개방-폐쇄 원칙을 적용하는 방법의 하나이다.

 

  방문자 패턴을 이용하게 되면 객체에 방문한 방문자는 구조체의 특정 요소를 작업할 수 있다. 그래서 객체를 직접 수정하지 않아도 방문자에게서 새로운 기능을 얻게 된다. 즉, 방문자 패턴으로 객체의 구조를 탐색하고 객체의 요소에 작동하며 수정하지 않고 기능을  확장하는 것이 가능하다. 

  이 책에서는 이 방문자 패턴을 이용하여 파워업 등의 부스터로 개체의 단일 능력을 향상시키는 것을 구현했다. 

※ 파워업 매커니즘: 여러 속성을 동시에 강화할 수 있으며, 효과는 계속해서 지속된다. 

 

 

  위의 그림에서 Visitor는 방문자가 되고자 하는 클래스가 구현해야 하는 인터페이스다. 방문자 클래스는 방문할 수 있는 각 요소에 대해서 메서드를 구현해야 한다.

Element는 방문이 가능한 클래스가 구현해야 하는 인터페이스다. 방문자가 방문하는 진입점을 제공하는 Accept 메서드가 포함된다.

주의: 이 책의 코드에서 소개하는 방문자 패턴에서는 방문한 객체의 일부 속성을 변경한다. 

 

 

 

 

방문자 패턴의 장점

  • 개방/폐쇄: 직접 수정하지 않고 다른 클래스의 오브젝트와 함께 작동하는 새로운 동작을 추가할 수 있다.
  • 단일 책임: 방문자 패턴은 데이터를 보유하는 객체를 가질 수 있고, 또 다른 객체는 특정 행동을 도입하는 책임을 진다는 단일 책임 원칙을 준수한다. 

 

 

방문자 패턴의 단점

  • 접근성: 클래스에서 패턴을 사용하지 않을 때보다 더 많은 공개 속성을 노출해야 할 수도 있다. 특정 개인 필드/메서드 접근이 적을 수 있기 때문이다.
  • 복잡성: 구조적으로 싱글톤, 상태, 오브젝트 풀 등의 패턴보다 훨씬 복잡하다. 코드베이스가 복잡해질 가능성이 있다. 

 

 

 

책의 구현

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

 

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. Interface 설계

1-1. IBikeElement

  방문할 수 있는 각 요소가 구현해야 하는 인터페이스다. 이 코드에서는 Bike를 역할에 따라 작은 객체 단위로 나누어 작은 요소별로 속성을 갖고, 그 속성을 방문자 패턴을 통해 수정하는 형식으로 구현했다. 

public interface IBikeElement
{
	void Accept(IVisitor visitor);
}

 

 

1-2. IVistor

  Visitor(방문자)는 각각의 요소별로 방문했을 때의 기능을 정의해야 한다. 방문자 클래스는 방문할 수 있는 각 요소에 대해서 메서드를 구현해야 한다.

public interface IVisitor
{
	void Visit(BikeShield bikeShield);
	void Visit(BikeWeapon bikeWeapon);
	void Visit(BikeEngine bikeEngine);
}

 

 

2. 인터페이스 상속

2-1. PowerUp (IVisitor를 상속)

  실제 파워업 정보를 담은 객체의 코드다. Scriptable Object로, 파워업 애셋을 만들 수 있도록 CreateAssetMenu 속성을 가지고 있다. 만들어진 후 인스펙터에서 파라미터 속성을 각각 변경할 수 있다. IVisitor 인터페이스를 상속받고 있어서 방문할 수 있는 각 요소에 대한 메서드를 구현하고 있다. 

[CreateAssetMenu(fileName = "PowerUp", menuName = "PowerUp")]
public class PowerUp : ScriptableObject, IVisitor
{
	public string powerupName;
	public GameObject powerupPrefab;
	public string powerupDescription;

	[Tooltip("Fully heal shield")]
	public bool healShield;

	[Range(0.0f, 50f)]
	[Tooltip("Boost turbo settings up to increments of 50/mph")]
	public float turboBoost;

	[Range(0.0f, 25f)]
	[Tooltip("Boost weapon range in increments of up to 25 units")]
	public int weaponRange;

	[Range(0.0f, 50f)]
	[Tooltip("Boost weapon strength in increments of up to 50%")]
	public float weaponStrength;

	public void Visit(BikeShield bikeShield)
	{
		if (healShield)
			bikeShield.health = 100.0f;
	}

	public void Visit(BikeWeapon bikeWeapon)
	{
		int range = bikeWeapon.range += weaponRange;
		if (range >= bikeWeapon.maxRagne)
			bikeWeapon.range = bikeWeapon.maxRagne;
		else bikeWeapon.range = range;

		float strength = bikeWeapon.strength += Mathf.Round(bikeWeapon.strength * weaponStrength / 100);
		if (strength > bikeWeapon.maxStrength)
			bikeWeapon.strength = bikeWeapon.maxStrength;
		else bikeWeapon.strength = strength;
	}

	public void Visit(BikeEngine bikeEngine)
	{
		float boost = bikeEngine.turboBoost += turboBoost;
		if ( boost < 0.0f ) 
			bikeEngine.turboBoost = 0.0f;
		if (boost >= bikeEngine.maxTurboBoost) 
			bikeEngine.turboBoost = bikeEngine.maxTurboBoost;
	}
}

 

 

2-2. IBikeElement 상속

  IBikeElement를 상속받는 것은 BikeShield, BikeEngine, BikeWeapon이다. IBikeElement를 상속받으면 Visitor가 방문했을 때 사용할 Accept(IVisitor visitor)를 구현해야 한다. Accept의 구현은 모두 같으니 BikeShield의 코드만 올리도록 하겠다.

public class BikeShield : MonoBehaviour, IBikeElement
{
	public float health = 50.0f;

	public float Damage(float damage)
	{
		health -= damage;
		return health;
	}

	public void Accept(IVisitor visitor)
	{
		visitor.Visit(this);
	}

	void OnGUI()
	{
		GUI.color = Color.green;
		GUI.Label(new Rect(125, 0, 200, 0), "Shield Health: " + health);
	}
}

 

 

3. 사용

  BikeController는 IBikeElement를 상속받고 있다. 즉, Accept(IVisitor visitor)를 구현하고 있다. 또한 BikeController는 BikeEngine, BikeShield, BikeWeapon 등의 객체를 포함하고 있으며, 그 객체들을 _bikeElements라는 리스트에 넣어 관리하고 있다. Accept(IVisitor visitor)가 불리면 이 객체들(_bikeElements 안의 객체들)을 순회하면서 Visitor에 정의된 Visit(IBikeElement 클래스)를 호출하여 각각의 속성을 변경하게 된다. 

public class BikeController : Subject, IBikeElement
{
    private List<IBikeElement> _bikeElements = new List<IBikeElement>();
    
    private void Start()
    {
        _bikeElements.Add(gameObject.AddComponent<BikeShield>());
        _bikeElements.Add(gameObject.AddComponent<BikeEngine>());
        _bikeElements.Add(gameObject.AddComponent<BikeWeapon>());
    }

    public void Accept(IVisitor visitor)
    {
        foreach (IBikeElement element in _bikeElements)
        {
            element.Accept(visitor);
        }
    }
}

 

 

 

 

코드의 흐름과 순서, 작동 원리 등을 이해하는 데 시간이 많이 걸린 패턴이다. 이곳에 적기는 부끄러운 내용이지만, 처음에는 어차피 PowerUp의 Visit(~~) 함수들을 모두 호출하게 되는데, 방문 가능한 클래스별로 Visit 함수를 나눈 것이 의미가 있나?라는 생각을 했었다. 하지만 매개변수로 전달되는 클래스가 전부 다르기 때문에 매개변수 객체의 값을 각각 수정할 수 있다는 것에 의미가 있을 것이라는 생각이 들었다. 단일 책임 원칙에 따라 하나의 클래스는 하나의 기능만 담당하는 것이 맞기 때문에 클래스를 나누고, 그에 따라 Visit를 모두 따로 만드는 것이 맞다는 것을 알았다.