ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 게임 프로그래밍 패턴 Part 2 디자인 패턴 다시 보기 - 명령
    보관함 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#의 딜리게이트 기능을 이용하여 구현하였으나 책에서는 클로저를 지원하지 않는 언어에서 클로저를 흉내 내어 명령 패턴을 구현하는 방법을 다룬다.

    댓글

Designed by Tistory.