보관함

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

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

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

 

이번 포스팅에서는 Part 2의 첫 번째 패턴인 명령 패턴을 살펴보고 유니티로 구현해 보는 것을 목표로 한다.


명령 패턴이란?)

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

요청 자체를 캡슐화하는 것입니다. 이를 통해 요청이 서로 다른 사용자(Client)를 매개변수로 만들고, 요청을 대기시키거나 로깅하며, 되돌릴 수 있는 연산을 지원합니다.

또, 명령 패턴에 대해 책의 저자는 다음과 같이 요약했다.

명령 패턴은 메서드 호출을 실체화(Reify)한 것이다.

여기에서 실체화 한다는 것은 변수에 저장하거나 함수에 전달할 수 있도록 객체로 만든다는 의미이다.

 

구현 준비 및 목표)

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

Unity Learn - 2D Roguelike

구현 목표는 다음과 같다.

    1. 플레이어 조작에 명령 패턴을 적용한다.

    2. 몬스터에 조작에 명령 패턴을 적용한다.

    3. 몬스터 AI를 추가한다.


저자는 명령 패턴은 AI 엔진과 액터 사이에 인터페이스용으로 사용할 수 있으며 이를 통해 각 액터(여기에서는 몬스터) 마다 다른 AI를 적용할 수 있다고 설명한다.

 

구현 목표 3번은 이를 구현하고자 하는 것이다.


구현 과정)

| 플레이어 조작에 명령 패턴 적용

먼저 이 예제에서 플레이어를 움직이는 코드는 다음과 같다.

// Player.cs -> MovingObject 상속
private void Update()
{
    if (!GameManager.instance.playersTurn) return;

    int horizontal = 0;
    int vertical = 0;

    horizontal = (int)(Input.GetAxisRaw("Horizontal"));

    vertical = (int)(Input.GetAxisRaw("Vertical"));

    if (horizontal != 0)
    {
        vertical = 0;
    }

    if (horizontal != 0 || vertical != 0)
    {
        AttemptMove<Wall>(horizontal, vertical);
    }
}

protected override void AttemptMove<T>(int xDir, int yDir)
{
    food--;
    foodText.text = $"Food : {food}";

    base.AttemptMove<T>(xDir, yDir);

    RaycastHit2D hit;

    if (Move(xDir, yDir, out hit))
    {
        SoundManager.instance.RandomizeSfx(moveSound1, moveSound2);
    }

    CheckIfGameOver();

    GameManager.instance.playersTurn = false;
}

// MovingObject.cs
protected virtual void AttemptMove<T>(int xDir, int yDir)
        where T : Component
{
    RaycastHit2D hit;

    bool canMove = Move(xDir, yDir, out hit);

    if (hit.transform == null)
        return;

    T hitComponent = hit.transform.GetComponent<T>();

    if (!canMove && hitComponent != null)
        OnCantMove(hitComponent);
}

 

플레이어에서 입력을 확인하는 부분을 InputManager라는 클래스를 만들어 분리했다. 코드는 다음과 같다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class InputManager : MonoBehaviour
{
    public static InputManager instance;

    public System.Action LeftButton { get; set; }
    public System.Action RightButton { get; set; }
    public System.Action UpButton { get; set; }
    public System.Action DownButton { get; set; }

    private KeyCode m_pressedKey;

    private void Start()
    {
        if (instance == null)
        {
            instance = this;
        }
        else if (instance != this)
        {
            Destroy(gameObject);
            return;
        }

        DontDestroyOnLoad(gameObject);
    }

    void Update()
    {
        int horizontal = 0;
        int vertical = 0;

        horizontal = (int)(Input.GetAxisRaw("Horizontal"));

        vertical = (int)(Input.GetAxisRaw("Vertical"));

        if (horizontal != 0)
        {
            vertical = 0;
        }

        if(horizontal < 0)
        {
            m_pressedKey = KeyCode.LeftArrow;
        }
        else if(horizontal > 0)
        {
            m_pressedKey = KeyCode.RightArrow;
        }
        if (vertical < 0)
        {
            m_pressedKey = KeyCode.DownArrow;
        }
        else if (vertical > 0)
        {
            m_pressedKey = KeyCode.UpArrow;
        }
    }

    public System.Action HandleInput()
    {
        switch(m_pressedKey)
        {
            case KeyCode.LeftArrow:
                m_pressedKey = KeyCode.None;
                return LeftButton;
            case KeyCode.RightArrow:
                m_pressedKey = KeyCode.None;
                return RightButton;
            case KeyCode.DownArrow:
                m_pressedKey = KeyCode.None;
                return DownButton;
            case KeyCode.UpArrow:
                m_pressedKey = KeyCode.None;
                return UpButton;
        }
        return null;
    }
}

 

플레이어는 다음과 같이 Start메서드에서 InputManager의 버튼에 캐릭터를 움직이는 함수를 적용한다.

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 MoveLeft()
{
    AttemptMove<Wall>(xDir : -1);
}

private void MoveRight()
{
    AttemptMove<Wall>(xDir: 1);
}

private void MoveUp()
{
    AttemptMove<Wall>(yDir: 1);
}

private void MoveDown()
{
    AttemptMove<Wall>(yDir: -1);
}

 

이 시도에 대한 결과는 다음과 같았다.

 

한 번 이동 버튼을 눌렀을 때 두 번 이동이 적용되는 것을 확인할 수 있다. 이 문제의 원인을 파악하기 위해 InputManager에 다음 코드를 추가하여 로그를 확인했다.

Debug.Log($"vert:{vertical} horiz:{horizontal}");

한 번 입력을 넣었을 때 한 번만 처리되는 게 아니라 여러 번 입력이 적용되고 있었다.

이 것을 막기 위해서 m_pressedKeyKeyCode.None을 넣어주는 코드를 Update 메소드에 옮겨 적용했다.

void Update()
{
    // ... 이전 코드 생략
    else if (vertical < 0)
    {
        m_pressedKey = KeyCode.DownArrow;
    }
    else if (vertical > 0)
    {
        m_pressedKey = KeyCode.UpArrow;
    }
    else
    {
    	// 초기화 코드 추가
        m_pressedKey = KeyCode.None;
    }
}

public System.Action HandleInput()
{
    switch(m_pressedKey)
    {
        case KeyCode.LeftArrow:
        	// 초기화 코드 제거
            return LeftButton;
        case KeyCode.RightArrow:
        	// 초기화 코드 제거
            return RightButton;
        case KeyCode.DownArrow:
        	// 초기화 코드 제거
            return DownButton;
        case KeyCode.UpArrow:
        	// 초기화 코드 제거
            return UpButton;
    }
    return null;
}​

이후 정상동작이 확인되었다.

 

 

| 몬스터 조작에 명령 패턴 적용

몬스터를 조작하는 코드는 다음과 같다.

// Enemy.cs
public void MoveEnemy()
{
    int xDir = 0;
    int yDir = 0;

    if (Mathf.Abs(target.position.x - transform.position.x) < float.Epsilon)
        yDir = target.position.y > transform.position.y ? 1 : -1;
    else
        xDir = target.position.x > transform.position.x ? 1 : -1;

    AttemptMove<Player>(xDir, yDir);
}

// GameManager.cs
IEnumerator MoveEnemies()
{
    enemiesMoving = true;

    yield return new WaitForSeconds(turnDelay);

    if (enemies.Count == 0)
    {
        yield return new WaitForSeconds(turnDelay);
    }

    for (int i = 0; i < enemies.Count; i++)
    {
        enemies[i].MoveEnemy();

        yield return new WaitForSeconds(enemies[i].moveTime);
    }
        
    playersTurn = true;

    enemiesMoving = false;
}

 

이 부분도 마찬가지로 AIManager를 만들어 AIManager에서 어떤 위치로 이동할지 결정하도록 만들었다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class AIManager : MonoBehaviour
{
    public static AIManager instance;

    public delegate object AIMode(Enemy enemy, Transform target);

    public AIMode SimpleAI { private set; get; }

    private void Start()
    {
        if (instance == null)
        {
            instance = this;
        }
        else if (instance != this)
        {
            Destroy(gameObject);
            return;
        }

        DontDestroyOnLoad(gameObject);

        SimpleAI = Simple;
    }

    object Simple(Enemy enemy, Transform target)
    {
        int xDir = 0;
        int yDir = 0;

        if (Mathf.Abs(target.position.x - transform.position.x) < float.Epsilon)
            yDir = target.position.y > transform.position.y ? 1 : -1;
        else
            xDir = target.position.x > transform.position.x ? 1 : -1;

        return new { x = xDir, y = yDir };
    }
}

 

 

EnemyMoveEnemy 메소드를 AIManager에서 받은 위치를 적요하도록 수정했다.

public void MoveEnemy()
{
    dynamic movePos = AIManager.instance.SimpleAI(this, target);
   
    AttemptMove<Player>(movePos.x, movePos.y);
}

 

이를 통해 기존처럼 이동이 가능하도록 처리할 수 있었다.

 

| 몬스터 패턴 추가

AIManager에 다음 코드를 추가한 뒤 Enemy에 1과 2중 랜덤 하게 부여하여 1일 때는 Simple을 2일 때는 Odd를 사용하도록 설정했다.

object Odd(Enemy enemy, Transform target)
{
    int xDir = 0;
    int yDir = 0;

    if (Mathf.Abs(target.position.x - enemy.transform.position.x) < float.Epsilon)
        yDir = target.position.y < enemy.transform.position.y ? 1 : -1;
    else
        xDir = target.position.x < enemy.transform.position.x ? 1 : -1;

    return new { x = xDir, y = yDir };
}

 

다음과 같이 플레이어를 보면 피하는 AI를 적용할 수 있었다.

 

결론)

플레이어 조작 및 몬스터 조작의 경우 기존 로직을 벗어나지 않게 하기 위해 적잖게 타협을 해야 했기에 크게 좋다는 것을 확인하기 어려웠다. 그러나 AI를 적용하는 부분은 분명히 쉽게 다른 AI로 변경할 수 있음을 확인할 수 있었다.

 

실제 게임에 적용한다면 엘리트 몬스터를 만들 때 해당 몬스터에 AI를 추가 적용하는 경우에 사용하면 굉장히 좋을 것 같다.

 

추가 정보)

  • 명령 패턴은 위에서 언급한 것 외에 실행취소 및 재실행을 구현하는 데 사용할 수 있다.
  • 포스팅에서는 C#의 딜리게이트 기능을 이용하여 구현하였으나 책에서는 클로저를 지원하지 않는 언어에서 클로저를 흉내 내어 명령 패턴을 구현하는 방법을 다룬다.