-
게임 프로그래밍 패턴 Part 2 디자인 패턴 다시 보기 - 명령보관함 2020. 1. 18. 19:43
이 시리즈는 [게임 프로그래밍 패턴]에 등장하는 팁을 정리하고 패턴을 직접 구현하거나 구현되어 있는 패턴을 확인하는 것으로 해당 패턴에 대해 이해하는 것을 목표로 한다.
이번 포스팅에서는 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_pressedKey에 KeyCode.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 }; } }
Enemy의 MoveEnemy 메소드를 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#의 딜리게이트 기능을 이용하여 구현하였으나 책에서는 클로저를 지원하지 않는 언어에서 클로저를 흉내 내어 명령 패턴을 구현하는 방법을 다룬다.