i_jemin

Archive

← 아카이브로

게임 프로그래밍 패턴

익혀 두었던 디자인 패턴을 복습하며 정리한 글이다. 모든 패턴을 다루지는 않으며, 정의와 요약은 아래 자료를 참고했다. 예제는 이해하기 쉬운 형태로 작성했고 일부 구현은 생략했다.

참고 자료

싱글톤 Singleton 패턴

인스턴스를 하나로 제한하고, 그 인스턴스에 접근하는 정적 접근자를 제공하는 패턴이다. 이 두 조건이 모두 필요한지 먼저 살펴보는 것이 중요하다.

아래는 Unity에서 필요할 때 AudioManager를 찾거나 생성하는 접근자 예제다. 이미 만들어진 중복 인스턴스를 제한하는 처리는 별도로 고려해야 한다.

Singleton.cs

public class AudioManager : MonoBehaviour {
	public static AudioManager Instance
	{
		get
		{
			if (!m_instance)
			{
				m_instance = FindObjectOfType<AudioManager>();

				if (!m_instance)
				{
					m_instance = new GameObject("Audio Manager").AddComponent<AudioManager>();
				}
			}
			return m_instance;
		}
	}
    
    private static AudioManager m_instance;
}

https://gist.github.com/IJEMIN/686d3a78ffb315614d826ba34a6ad13d

편리함과 제약

코드 어디에서나 같은 인스턴스를 사용할 수 있어 공유 자원에 접근하기 편하다. 예제처럼 최초 접근 때 생성하면 사용하지 않는 객체를 미리 만들지 않아도 된다. 다만 다음과 같은 비용이 있다.

  • 여러 곳에서 상태를 바꿀 수 있으므로 문제가 생겼을 때 변경 경로를 찾기 어렵다.
  • 관련 없는 클래스들이 같은 전역 객체에 의존하면서 결합도가 높아질 수 있다.
  • 인스턴스가 하나라는 제약 때문에 나중에 여러 인스턴스가 필요한 구조로 바꾸기 어렵다.
  • 최초 접근 시 초기화 비용이 발생하므로 생성 시점을 제어하기 어렵다.

예를 들어 하나의 Score에 접근하도록 만들었는데 나중에 여러 종류의 점수를 독립적으로 관리해야 한다면, 처음 정한 단일 인스턴스 구조가 제약이 될 수 있다.

객체마다 Manager를 추가하기 전에 해당 책임을 객체 자체에 둘 수 있는지 살펴보자. 하나의 인스턴스를 유지하는 것과 어디서나 접근할 수 있게 하는 것은 별개의 요구다. 필요한 객체를 매개변수로 전달하거나, 상위 객체의 제한된 접근자를 쓰거나, 서비스 중계자를 이용하는 방법도 있다.

Null Object 패턴

객체가 없음을 나타내는 null 대신 아무 일도 하지 않는 객체를 제공해, 사용하는 쪽에서 반복적으로 null을 검사하지 않도록 하는 방식이다.

C#의 빈 델리게이트

비슷한 아이디어로 이벤트를 빈 델리게이트로 초기화할 수 있다.

public event EventHandler MyButtonClick = delegate { };

아래는 전달받은 델리게이트 참조를 검사한 뒤 호출하는 확장 메서드 예제다.

public static void SafeInvoke(this EventHandler handler, object sender,
                              EventArgs e)
{
    if (handler != null)
    {
        handler(sender, e);
    }       
}

public virtual void OnMyButtonClick(EventArgs e)
{
    MyButtonClick.SafeInvoke(this, e);
}

호출할 때 받은 handler를 지역 참조로 사용한다. 원래 이벤트의 값과 이 참조를 구별해서 이해하는 것이 중요하다.

명령 Command 패턴

수행할 동작을 객체로 나타내는 패턴이다. 명령을 저장하거나 실행 시점을 늦추고, 여러 명령의 순서를 조합할 수 있다.

실행과 취소를 제공하는 인터페이스로 명령을 정의하면 편집기의 실행 취소 기능을 구성할 수 있다.

public interface ICommnad
{
    void execute();

    void undo();
}

public class Move: ICommand
{

    var beforePositionState;

    void execute()
    {
        beforePositionState = currentPositionState;
        // some move codes
    }

    void undo()
    {
        // some undo move codes to get back to beforePositionState
    }
}


public class Draw: ICommand
{

    var beforeCanvasState;

    void execute()
    {
        beforeCanvasState = currentCanvasState;
        // some draw codes
    }

    void undo()
    {
        // some undo draw codes to get back to beforeCanvasState
    }
}

/* ... 실행부 ... */

Stack<ICommand> commands = new Stack<ICommand>();

public void Execute(ICommand command)
{
    command.execute();
    commands.Add(command);
}

public void Undo()
{
    commands.Pop().undo();
}

명령을 실행할 때 이전 상태를 저장하고 실행한 명령을 스택에 쌓는다. 취소할 때는 마지막 명령을 꺼내 undo를 호출한다.

대화 스크립트의 각 줄을 명령 객체로 바꾸고 플레이어의 입력에 따라 실행하는 데에도 응용할 수 있다. 람다나 델리게이트로 동작을 표현할 수도 있지만, 실행 전 상태와 취소 동작을 함께 관리해야 한다면 명시적인 명령 객체가 유용하다.

경량 Flyweight 패턴

여러 인스턴스가 공통으로 사용하는 데이터를 하나의 객체에 모아 공유한다. 객체마다 같은 자원을 중복 생성하는 비용을 줄이는 것이 목적이다.

데이터는 공유할 수 있는 고유 상태와 인스턴스마다 다른 외부 상태로 나눈다. 나는 고유 상태를 “종류가 한정적이고 초기화 이후 바뀔 필요가 없는 값”으로 생각하면 이해하기 쉬웠다.

다음 예제는 몬스터마다 다른 정보와 종족별 공통 정보를 분리한다.

public class MonsterBlood
{
    private bool _isMale;
    private Mesh _skinMesh;
    private string _bloodName;

    public bool IsMale { return _isMale; }
    public Mesh SkinMesh { return _skinMesh; }
    public string BloodName { return _bloodName; }

    MonsterBlood(bool isMale, Mesh skinMesh, string bloodName)
    {
        this.isMale = isMale;
        this.skinMesh = skinMesh;
        this.bloodName = bloodName;
    }
}

public class MonsterBloods
{
    /* ...생략... */

    private static MonsterBlood _ghost = new MonsterBlood(true,ghostMesh,"유령");
    private static MonsterBlood _dragon = new MonsterBlood(false,dragonMesh,"드래곤");
    private static MonsterBlood _zombie = new MonsterBlood(true,zombieMesh,"좀비");

    public static MonsterBlood Ghost{ get{ return _ghost } }
    public static MonsterBlood Dragon{ get{ return _dragon } }
    public static MonsterBlood Zombie{ get{ return _zombie } }
}

public class Monster
{
    /* ...생략... */
    Monster(MonsterBlood blood, MonsterData otherData)
    {
        this._blood = blood;
        this._monsterData = otherData;
       //....생략
    }

    private MonsterBlood _blood;

    public MonsterBlood Blood{ return _blood; }

}


/** 새로 몬스터를 인스턴스화 할때, 종족과 관련된 정보와 메쉬를 그때 그때 생성활 필요가 없다. **/

Monster newZombie0 = new Monster(MonsterBloods.Zombie,somedatas0);
Monster newZombie1 = new Monster(MonsterBloods.Zombie,somedatas1);
Monster newZombie2 = new Monster(MonsterBloods.Zombie,somedatas2);
Monster newZombie3 = new Monster(MonsterBloods.Zombie,somedatas3);
Monster newZombie4 = new Monster(MonsterBloods.Zombie,somedatas4);
Monster newZombie5 = new Monster(MonsterBloods.Zombie,somedatas5);
Monster newZombie6 = new Monster(MonsterBloods.Zombie,somedatas6);
Monster newZombie7 = new Monster(MonsterBloods.Zombie,somedatas7);


각 좀비는 서로 다른 객체지만 종족 정보인 MonsterBloods.Zombie를 공유한다. 공통 메시나 종족 이름을 좀비마다 새로 만들 필요가 없다.

같은 그래픽 자원을 공유하면서 위치와 크기 등만 다르게 전달하는 인스턴스 렌더링에서도 비슷한 분리를 볼 수 있다.

관찰자 Observer 패턴

대상은 어떤 일이 일어났는지 알리고, 등록된 관찰자는 알림을 받아 자신의 일을 수행한다. 대상이 관찰자의 구체적인 동작을 몰라도 된다는 점이 핵심이다.

/** 사실 C#은 event와 delegate키워드가 있기 때문에 실제로 C#에선 이보다 매우 간단하다. **/

public enum GameEvent { DEATH,HIT,ATTACK };

public Interface IObserver
{
    void OnNotify(const Object entity, GameEvent gameEvent);
}

public class AchievementManger: IObserver
{
    void OnNotify(const Object entity, GameEvent gameEvent)
    {
        if(gameEvent == GameEvent.DEATH)
        {
            Console.WriteLine(entity.ToString() + "이 죽었습니다! \"플레이어 사망\" 도전과제 해제!");
        }
    }
}

public class Subject
{
    protected List<IObserver> observers = new List<IObserver>();

    public void AddObserver(IObserver observer)
    {
        observers.Add(observer);
    }

    public void ClearObservers()
    {
        observers.Clear();
    }
    
    protected void Notify(const Object entity, GameEvent gameEvent)
    {
        foreach(var observer in observers)
        {
            if(observer != null)
            {
                observer.OnNotify();
            }
            else
            {
                observers.Remove(observer);
            }
        }

    }
}

public class Player: Subject
{
    /* ...중간 코드 생략... */
    private void OnDeath()
    {
        //적절한 죽음과 관련된 코드
        OnNotify(this,GameEvent.DEATH);
    }
}

/** 런타임에서 **/

myPlayer.AddObserver(achievementManager);

/* ... */

if(myPlayer.hp <= 0)
{
      myPlayer.OnDeath();
}

Player는 자신이 죽었다는 알림을 보내지만, 도전과제 관리자가 그 알림을 받아 무엇을 하는지는 알 필요가 없다. 플레이어의 사망 처리 안에 도전과제 해제 코드를 직접 넣지 않아도 된다.

이 예제는 관찰자를 순서대로 호출하므로 모든 처리가 끝날 때까지 기다린다. 무거운 작업은 작업 큐 등으로 분리할 수 있다. 멀티스레드를 사용한다면 공유 상태와 잠금 처리도 고려해야 한다.

더 이상 필요하지 않은 관찰자는 등록을 해제해야 한다. 대상이 계속 참조하고 있으면 다른 곳의 참조를 없애더라도 가비지 컬렉터가 회수하지 못할 수 있다.

관찰자를 저장하는 방식으로는 연결 리스트나 intrusive 연결 리스트도 고려할 수 있다. 다만 패턴을 도입하면 호출 흐름이 간접적이 된다. 서로 밀접한 기능을 불필요하게 분리하는 것은 아닌지 살펴보자.

프로토타입 Prototype 패턴

기존 객체를 원형으로 삼아 새 객체를 만드는 패턴이다. 원형을 복제한 뒤 일부 값을 바꾸면 여러 변형을 구성할 수 있다.

public class GameCharacter
{
    public virtual GameCharacter Clone();
}

public class Hunter : GameCharacter
{
    //어떤 코드들
    public GameCharacter Clone()
    {
        return new Hunter(attackPoint, defensePoint, speed);
    }
}

public class Warrior : GameCharacter
{
    //어떤 코드들
    public GameCharacter Clone()
    {
        return new GameCharacter(attackPoint, defensePoint, speed);
    }
}

//새로 객체를 만들고 싶으면 원본 프로토 객체로 부터 복사 생성한다.

//원본
GameCharacter prototypicalWarrior = new Warrior(100,20,10);

//새로운 객체를 원형 인스턴스로부터 생성. 그리고 이것이 또다른 객체들의 원형이 될수도 있다.
GameCharacter prototypicalFastWarrior = prototypicalWarrior.Clone();
prototypicalFastWarrior.speed = 30;

//

fastWarrior0 = prototypicalFastWarrior.Clone();
fastWarrior1 = prototypicalFastWarrior.Clone();

commonWarrior0 = prototypicalWarrior.Clone();
commonWarrior1 = prototypicalWarrior.Clone();


게임 데이터에서도 기본형을 두고 변형에 필요한 값만 따로 적으면 중복을 줄일 수 있다.

//게임 프로그래밍 패턴 책을 바탕으로 구성한 예제.

BaseHunter
{
    "이름": "사냥꾼",
    "기본 스킬": ["베기", "자르기"],
    "체력" : 50,
    "공격력" : 40,
    "방어력" : 20,
}

FastHunter
{
    "프로토타입": "BaseHunter",
    "이름": "빠른 사냥꾼",
    "속도": 120,
    "추가 스킬" : ["발도","류요 와가 테키오 쿠라에"]
}

StrongHunter
{
    "프로토타입": "BaseHunter",
    "이름": "힘쎈 사냥꾼",
    "힘": 100,
    "공격력": 60,
    "추가 스킬" : ["발도","류진노 켄오 쿠라에"]
}

이 예제에서 FastHunter와 StrongHunter는 공통 데이터의 원형으로 BaseHunter를 지정한다. JSON 자체에 상속 기능이 있는 것은 아니므로, 자신에게 없는 값을 원형에서 찾는 처리는 데이터를 읽는 쪽에서 구현해야 한다.

상태 State 패턴

점프·달리기·슬라이딩·앉기를 처리한다고 해보자. 상태를 여러 bool 변수로 나타내면 동작이 늘어날수록 조건도 복잡해진다.

void Update()
{
    if(Input.RShift)
    {
        if(!m_isRun && !m_isAir && !m_isSlide)
        {
            player.TryRun();
        }
    }
    else if (Input.Space)
    {
        if(!m_isAir)
        {
            player.TryJump();
        }
    }
    else if (Input.Crouch)
    {
        if(!m_isAir && !m_isRun && !m_isSlide)
        {
            player.TryCrouch();
        }
        else if (!m_isRun)
        {
            player.TrySlide();
        }
    }
}

서로 동시에 성립하면 안 되는 상태까지 조합할 수 있다는 문제가 있다. 이런 경우에는 유한 상태 머신을 이용해 상태와 전이를 명시할 수 있다.

  • 가질 수 있는 상태의 종류가 정해져 있다.
  • 한 시점에는 하나의 상태를 가진다.
  • 입력이나 이벤트를 받아 정해진 전이를 수행한다.

서로 배타적인 상태를 열거형과 switch문으로 표현하는 예제다.

public enum STATE {IDLE,RUN,CROUCH,SLIDE,AIR};


STATE m_playerState = STATE.IDLE:

void Update()
{
    switch(m_platerState)
    {
        case STATE.AIR:
            //공중에 있을시 점프 불가능
            break;
        case STATE:IDLE:
            if(Input.Run)
                player.TryRun();
                //......
            break;

/** 나머지 부분은 다들 어떻게 될지 알것이다 **/
    }
}

상태별로 입력 처리를 모으면 여러 플래그를 조합할 때 생기는 혼란을 줄일 수 있다.

상태 자체에 정보가 필요하면 객체로 분리하는 방법도 있다. 달리기를 얼마나 오래 했는지, 충전 에너지가 얼마나 쌓였는지 등을 해당 상태와 함께 관리하는 방식이다.