게임톤 3주차가 지나가고 있습니다. 레벨디자인 툴은 초보적인 수준으로 만들어졌고, 이제 도토리와 나무를 생성하는 정도는 가능해졌죠. 스테이지 시작과 클리어 시의 효과가 없다시피해서 이게 매력적인 게임인지는 아직 모르겠습니다만, 살을 붙여나가면서 결과를 지켜봐야겠죠.

지금껏 프로토타입을 뜯어고치면서 이어져온 고민은, 어떻게 하면 이 프로젝트가 매력적인 포트폴리오가 될까하는 것이었습니다. 결국 하이퍼캐주얼로 복잡하지도 않은 게임을 만드는데 이게 현직자의 눈에 띄려면, 코드의 양보다는 질로 승부하는 게 낫지 않을까? 아니, 아직 절반도 채 완성되지 않았는데 무슨 코드의 질을 신경쓴단 말인가. 오버엔지니어링 때문에 정작 게임을 완성할 힘이 남아있지 않다면, 더티 코딩으로 완성한 게임보다 못하지 않나? 결국 게임의 완성이 목표라면…

UniTask를 활용해 구현한 코루틴을 대체한 더 효율적이고 직관적인 async Streaming Method와 UI Animation, 이정도면 충분할까 싶기도 하지만 좀 더 본질에 가까운 게 필요하지 않을까 싶었습니다. 그래서 저번에 충동구매했다가 책장 속에 고이 모셔뒀던 책을 좀 꺼내서 보기로 했죠. 로버트 나이스트롬의 게임 프로그래밍 패턴입니다.

게임톤이 어디까지나 게임 엔진을 만드는 프로젝트는 아니기에, 이벤트 큐게임 루프까지 신경 쓸 건 없겠죠. 대신 엔진을 활용하는 단계에서도 충분히 써먹을 만한 패턴은 적극적으로 써보기로 했습니다.

서비스 중개자, 인터페이스 분리

게임을 프로그래밍 하다보면, 전역 객체로 모든 문제를 해결해버리고 싶은 욕구가 마구 샘솟습니다. 객체를 생성하고, 그 포인터를 가지고 있으면서 메소드를 호출하는 것보다는 그냥 클래스에서 바로 static 메소드를 호출하거나 싱글턴 instance로부터 호출하는 편이 훨씬 편하겠죠. 특정한 목적을 위한 정적 메소드 집합을 정의해놓은 클래스를 서비스 제공자라고 한다면, SoundManager, FileManager, AchievementManager 등은 모두 포함되겠군요. 전역global이라는 건 편하긴 하지만, 그만한 비용을 수반합니다. 나중에는 업적을 관리할 오브젝트도 추가될 텐데요. 서비스 제공자 클래스의 인스턴스와 메소드가 정적이라는 점은 추후 메소드의 수정이나 변경 과정에서 감히 어떻게 손을 대야 할 지 감도 못 잡게 만들어버릴 겁니다. SoundManager::Play() 메소드의 패러미터를 하나 추가하고 내부 구조를 고치려고 봤더니, PlayerController와 GameManager, Enemy 클래스가 각각 게임 루프의 서로 다른 시점에 이 메소드를 전혀 다른 방식으로 호출하고 있습니다. 이제봤더니 AchievementManager의 정적 메소드 안에서도 이걸 써먹고 있습니다. 이걸 고쳐보려고 해도 코드는 점점 복잡해지고 클래스 간 관계는 거미줄처럼 꼬여가겠죠.

물론 유니티는 굳이 static으로 선언하지 않더라도, 싱글턴과 비슷한 구현 효과를 줄 수 있는 방법이 있습니다. GameManager에 DontDestroyOnLoad 속성을 준 뒤, private instance를 만들어 줍니다. GameManager 하나만 싱글턴이 되고, 이 GameManager 밑으로 각종 서비스 제공자들을 Child로 두게 되면 서비스 제공자들은 전역 인스턴스도, 정적 메소드도 없이 다양한 객체들에게 서비스를 제공할 수 있게 됩니다. 그저 GameManager 참조자만 갖고 있으면 되는 거죠. 서비스 중개자로서의 싱글턴…이려나요.

하지만 문제는 또 있습니다. 효과음 출력을 위한 메소드를 호출하기 위해 굳이 SoundManager를 찾고 또 메소드를 찾고 호출을 어떻게 하는지 그때그때 찾아보고…

private GameManager gm;

gm.soundManager.Play();

물론 이정도만 되어도 꽤 깔끔하겠다만, 디미터의 원칙을 상기한다면, 플레이어가 효과음을 실행하기 위해 굳이 GameManager가 SoundManager를 갖고 있다는 걸 알 필요 없이 바로 gm.PlaySound()처럼 호출한다면 더 좋지 않을까요?

예전에 만들었던 Unity 프로젝트의 GameManager는 이 점을 신경쓰기는 했지만, 각 Manager마다 메소드를 십수 개씩 달아주다보니 Monolithic해져버렸습니다. 플레이어가 gm을 찾는 순간 IDE는 화면이 꽉 차는 메소드 리스트를 보여줬죠.

GamaManager가 Sound와 관련된 인터페이스를 구현하고, 플레이어는 그 인터페이스 포인터를 갖고 있는 것으로 해결할 수 있겠죠.

public interface ISoundHandler
{
    public void Play();
    public void Stop();
}
public class GameManager : ISoundHandler
{
    private SoundManager sm;

    public void Play() 
    {
        sm.Play();
    }
}

물론 이런 구조를 만드는 건 좋지만, 슬슬 “이게 오버 엔지니어링이 아닌가”하는 의문이 들기 시작하는 것도 사실입니다.

FileManager도 인터페이스로 활용해보려 했죠.

public interface IFileHandler : IDataHandler
{
    public void Save();
    public void Load();
}

근데 정작 FileManager는 모든 메소드를 static으로 선언해놔서, 이게 필요가 없다는 겁니다! Unity의 서비스 제공자 객체는 그 특성상 MonoBehavior를 상속받아야 하는데, FileManager가 굳이 Start나 Update를 호출하지도 않고 충돌 판정도 없는데 이걸 상속받을 이유가 있나 싶어서 다 빼버렸거든요. 그렇다보니 씬 오브젝트의 컴포넌트로 갖다붙여놓을 수가 없어집니다. 흠, 이건 해결해야 할 과제가 되겠군요.

상태 기계

이번 프로젝트에 등장하는 플레이어는 3개의 상태를 갖습니다. Run, Jump, Climb - 달리다가, 나무를 만나면 타고 오릅니다. 그러다 입력이 있으면 무조건 점프만 하는 거죠. 이렇게 구현하려면 일단 땅에 붙어있는지 확인하는 bool 변수가 하나 필요하겠군요. 벽에 붙었는지 확인하는 변수도 필요하겠네요. if - else 로 확인하고, 그 안에서 또 if - else 로 확인하고… 물론 구현은 할 수 있습니다. 하지만 여러 상태를 bool 변수들로 구분하겠다면, 잠재된 위험이 있습니다. 바로 정의되지 않은 상태가 있다는 거죠. 땅에 붙어있지도 않고 나무를 타지도 않는다면 이건 Jump 상태겠네요. 그럼 땅에 붙어있으면서 동시에 나무를 타고 있다면 이건 무슨 상태일까요? Update에서 Run 상태일 때 if 블록 안에서 나무에 붙으면 바로 Climb쪽으로 넘어가도록 return 할 테니 별 문제 없다고 생각할지도 모르겠지만, 중첩되는 if - else 문은 늘 예상치 못한 곳, 블록 깊은 구석에 정의되지 않은 상태를 필연적으로 만들어 놓으니 이건 신경을 써야겠죠.

상호 배타적인 상태를 구분하기 위한 한 가지 해결 방법은, Enum을 쓰는 겁니다. 이렇게 되면 정의되지 않은 상태가 존재할 위험은 크게 줄어듭니다.

하지만 그것보다 훨씬 더 객체지향적인 해결 방법은, 아예 각 State를 클래스로 만들어버리는 겁니다! IState 인터페이스를 구현한 각 클래스는 오버라이딩된 각각의 Update 메소드로 각 상태의 적절한 처리를 해줄 겁니다. (JAVA는 이런 점에서 상당히 매력적인 언어일지도 모르겠습니다. Enum의 모든 레이블 각각이 Enum을 상속받는 클래스가 되니까요.)

public interface IState
{
    public IState Update();
}

public class Player
{
    IState state;

    void Update()
    {
        state = state.Update();
    }
}

각 상태는 내부 처리를 통해 그 상태를 유지할지, 아니면 다른 상태로 전이할지 반환되는 결과로 플레이어에게 알려줍니다. 이런 구조를 유한 상태 기계Finite State Machine라 할 수 있습니다. 튜링 머신으로써는 가장 단순한 기능을 갖죠. 튜링머신이라 부르기도 좀 그런게, FSM은 튜링머신이 읽어들일 수 있는 문자열의 극히 일부만을 읽어들일 수 있습니다. 정규, 재귀, 열거 등에 대해서는 내용이 너무 복잡해지니 굳이 이 글에서 다루지는 않겠습니다.

이 프로젝트의 플레이어의 상태는 지극히 단순하기 때문에 이 정도의 유한 상태 기계로도 충분히 구현이 가능했죠. 그런데, 게임 자체의 상태도 이러한 상태 기계로 관리될 수 있습니다! 이는 게임에 국한된 것이 아니죠. 안드로이드 애플리케이션 또한 상태 기계로서 존재합니다. 하지만 플레이어보다는 좀 더 고차원적인 행동이 가능하죠. 선택 버튼을 누르면 계속 세부 페이지로 이동하다가, 돌아가기를 누르면 이전의 페이지로 돌아갑니다. 어떤 페이지에서 들어왔는가에 따라 결과가 달라지죠. 플레이어와는 달리 이전 상태를 기억하는 겁니다.

게임도 이와 같습니다. 로비 화면에서 메뉴 창을 띄웠다가 내리면 로비로 돌아갑니다. 스테이지 대기 화면에서 메뉴 창을 띄웠다가 내리면 스테이지 대기 화면이겠죠. 로비에서 스테이지 대기 화면으로 들어갔다가, 거기서 메뉴 창을 띄운 후 창을 내린다고 해서 로비로 바로 돌아가지는 않죠. 이건 마치, 페이지를 밑에서부터 쌓아올렸다가 위에서부터 한 장씩 치우는 느낌 아닌가요?

public interface IGameState
{
    public void Enter();
    public void Update();
}

public class GameManager
{
    Stack<IGameState> state;

    void Update()
    {
        state.Peek().Update();
    }

    void Push()
    {
        state.Push(new GameState());
        state.Peek().Enter();
    }
}

이렇게 상태를 쌓아올린 것을 스택Stack이라고 합시다. 그리고 이 스택을 보조 도구로 갖는 상태 기계를 푸시다운 오토마타Push-down Automata라 합니다. 거의 대부분의-사실상 모든-웹 애플리케이션은 푸시다운 오토마타라 할 수 있죠. 게임도 페이지 관리에서는 이 구조를 쓰는 게 정석이라 할 수 있습니다.

여담으로, 이번 게임톤에서는 협업툴로 클라썸을 쓰는데요, 앱 버전에서 이 상태 기계가 좀 문제가 있는 것 같습니다. 알림을 여러 번 확인한 후 뒤로가기를 누르면, 알림이 왔을 당시의 대화 로그를 띄운 페이지로 돌아갑니다. 알림을 확인할 때마다 스택이 계속해서 쌓이는 거죠. 실행 성능에 영향을 주는지는 모르겠지만, 제 사용 경험으로는 썩 유쾌하지는 않군요.