-
게임 프로그래밍 패턴 Part 2 디자인 패턴 다시 보기 - 명령보관함 2020. 1. 18. 19:43
이 시리즈는 [게임 프로그래밍 패턴]에 등장하는 팁을 정리하고 패턴을 직접 구현하거나 구현되어 있는 패턴을 확인하는 것으로 해당 패턴에 대해 이해하는 것을 목표로 한다.
이번 포스팅에서는 Part 2의 첫 번째 패턴인 명령 패턴을 살펴보고 유니티로 구현해 보는 것을 목표로 한다.
명령 패턴이란?)
[GoF디자인 패턴]에서는 명령 패턴을 다음과 같이 소개했다.
요청 자체를 캡슐화하는 것입니다. 이를 통해 요청이 서로 다른 사용자(Client)를 매개변수로 만들고, 요청을 대기시키거나 로깅하며, 되돌릴 수 있는 연산을 지원합니다.
또, 명령 패턴에 대해 책의 저자는 다음과 같이 요약했다.
명령 패턴은 메서드 호출을 실체화(Reify)한 것이다.
여기에서 실체화 한다는 것은 변수에 저장하거나 함수에 전달할 수 있도록 객체로 만든다는 의미이다.
구현 준비 및 목표)
유티니 2019를 사용하여 튜토리얼 프로젝트 중 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#의 딜리게이트 기능을 이용하여 구현하였으나 책에서는 클로저를 지원하지 않는 언어에서 클로저를 흉내 내어 명령 패턴을 구현하는 방법을 다룬다.