본문 바로가기
Games/게임 디자인 패턴

[게임 디자인 패턴] 커맨드 패턴

by NCTP 2024. 8. 5.

// 유니티로 배우는 게임 디자인 패턴 제 2판 (데이비드 바론, 구진수)에 대한 공부 요약 글입니다.

 

 

개요

이번 글에서는 커맨드 패턴에 대해서 공부하고, 예제를 통해 이해해보자.

 

커맨트 패턴의 매커니즘은 플레이어 컨트롤러 입력과 그에 해당하는 타임스탬프를 기록한다.

커맨드 패턴을 사용하여 게임의 리플레이 기능을 구현해보자.

 

  • 커맨드 패턴 이해하기
  • 불을 끄고 키는 기능을 구현하기
  • 몇 가지 대안 살펴보기

 

커맨드 패턴이란,

커맨드 패턴(Command Pattern)은 디자인 패턴 중 하나로, 요청을 캡슐화하여 객체로 만드는 디자인 패턴이다. 이를 통해 서로 다른 요청을 사용자가 선택적으로 사용할 수 있도록 하거나, 요청의 큐잉 및 로깅, 되돌리기(undo) 기능 등을 쉽게 구현할 수 있다.

 

커맨드 패턴의 구성 요소는 다음과 같다.

  • 커맨드 인터페이스(Command Interface) : 실행될 작업을 정의하는 인터페이스, 일반적으로 'execute' 라는 메서드를 포함하고 있다.
  • 콘크리트 커맨드(Concrete Command) : 커맨드 인터페이스를 구현하여 실제로 실행될 로직을 정의하는 클래스. 이 클래스는 요청을 처리한 리시버(Receiver)를 알고 있으며, 'execute' 메서드를 호출하면 리시버의 메서드를 호출한다.
  • 리시버(Receiver) : 실제 작업을 수행하는 주체. 콘크리트 커맨드에서 이 객체의 메서드를 호출하여 작업을 수행.
  • 인보커(Invoker) : 'Command' 객체를 실행하는 주체.  보통 여러 개의 커맨드를 저장하고, 필요할 때 커맨드를 실행하거나 취소활 수 있다.
  • 클라이언트(Client) : 콘크리트 커맨드와 리시버를 생성하고 연결, 인보커에 커맨드를 설정하는 역할을 한다.

 

커맨드 패턴의 장단점

장점

  • 명령 캡슐화 : 요청을 객체로 캡슐화하여 다른 객체와 독립적으로 매개변수화할 수 있다.
  • 실행 취소 : 명령을 취소할 수 있는 기능을 쉽게 구현 가능하다.
  • 확장성 : 새로운 명령이 추가되더라도 기존 코드에 영향을 적게 주며 확장이 가능하다.

단점

  • 복잡성 : 각 명령이 그 자체로 클래스가 되기 때문에, 커맨드 패턴을 구현하기 위해선 수많은 클래스가 필요하다. 목표를 잘 설정하고 커맨드 패턴을 사용하지 않으면 유지보수에 독이 될 수 있으니 주의하자.

 

예제 코드 살펴보기

  이제 커맨드 패턴을 통해서 플레이어 점프 기능을 구현해보자.

 

  먼저 추상 클래스를 통해 Command 인터페이스를 작성하자.

// Command Interface
public abstract class Command
{
    public abstract void Execute();
}

 

 

  앞으로 하나의 커맨드 클래스들은 이 Command 추상 클래스를 상속받는다.

이제 Jump 커맨드를 작성하자.

 

 

public class PlayerJump : Command
{
    private PlayerController _playerController;

    // 생성자
    public PlayerJump(PlayerController playerController)
    {
        _playerController = playerController;
    }
    // 실행할 내용을 포함하는 Execute 메서드
    public override void Execute()
    {
        _playerController.Jump();

    }
}

 

 

  Jump 커맨드는 Command 추상 클래스를 상속받고, Execute 메서드에 실행하고자 하는 기능이 작성되어있다.

Jump라는 커맨드를 캡슐화했으니, 이제 Invoker 클래스를 작성하여 호출해보자.

 

 

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

public class Invoker : MonoBehaviour
{
    private bool _isRecording;
    private bool _isReplaying;
    private float _replayTime;
    private float _recordingTime;
    //특정 명령이 실행된 때를 추적하는 배열
    private SortedList<float, Command> _recordedCommands = new SortedList<float, Command>();
    
    public void ExecuteCommand(Command command)
    {
        command.Execute(); // 커맨드 실행
        if(_isRecording) _recordedCommands.Add(_recordingTime, command);
        Debug.Log("Recorded Time : " + _recordingTime);
        Debug.Log("Recorded Command : " + command);
    }
    public void Record()
    {
        _recordingTime = 0.0f;
        _isRecording = true;
    }
}

 

 

  Invoker 클래스는 커맨드 실행 부서의 회계 담당이다.

커맨드 입력 명령을 처리하고, 어떤 커맨드가 언제 실행되었는지를 기록해준다.

 

  Record 메서드를 통해 원하는 언제든 기록을 시작할 수 있다.

이제 리플레이 기능을 추가해보자.

 

 

    public void Replay()
    {
        // 리플레이 시작 전 세팅
        _replayTime = 0.0f;
        _isReplaying = true;
        if(_recordedCommands.Count <= 0) Debug.LogError("No commands to replay!");
        _recordedCommands.Reverse();
    }

    void FixedUpdate()
    {
        if(_isRecording) _recordingTime += Time.deltaTime; //리코딩 시작
        if(_isReplaying) //리플레이 시작
        {
            _replayTime += Time.deltaTime;
            if(_recordedCommands.Any()) //기록된 커맨드가 존재한다면,
            {
                if(Mathf.Approximately(_replayTime, _recordedCommands.Keys[0]))
                {
                    Debug.Log("Replay Time : " + _replayTime);
                    Debug.Log("Replay Command : " + _recordedCommands.Values[0]);
                    _recordedCommands.Values[0].Execute();
                    _recordedCommands.RemoveAt(0);
                }

            }
            else //기록된 커맨드가 다 떨어졌다면 리플레이 종료
            {
                _isReplaying = false;
            }
        }
    }

 

 

  FixedUpdate() 에서 메서드를 시간에 맞춰 기록했다. (사전에 Invoker의 Record 메서드를 실행했다면.)

 

  이제 Replay 메서드를 사용하여 리플레이 기능을 활성화하고, FixedUpdate() 에 해당 로직이 작성되어있다.

 

  이제 기능을 테스트해보자. 플레이어가 입력한 점프를 그대로 리플레이할 것이다.

InputHandler 라는 클래스를 작성하여 플레이어 입력을 관리해보자. 이는 리시버의 역할을 한다고 보면 되겠다.

 

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

public class InputHandler : MonoBehaviour
{
    private Invoker _invoker;
    private bool _isReplaying;
    private bool _isRecording;
    private PlayerController _playerController;
    private Command _spaceBar;

    void Awake()
    {
        _invoker = gameObject.AddComponent<Invoker>();
        _playerController = FindObjectOfType<PlayerController>();
        _spaceBar= new PlayerJump(_playerController);
    }
    // Update is called once per frame
    void Update()
    {
        if(!_isReplaying && _isRecording)
        {
            if(Input.GetKeyDown(KeyCode.Space)) 
            {
                Debug.Log("점프!");
                _invoker.ExecuteCommand(_spaceBar);
            }
        }
    }
    void OnGUI()
    {
        if(GUILayout.Button("Start Recording"))
        {
            _isRecording = true;
            _isReplaying = false;
            _invoker.Record();
        }
        if(GUILayout.Button("Stop Recording"))
        {
            _isRecording = false;
        }

        if(!_isRecording)
        {
            if(GUILayout.Button("Start Replay"))
            {
                _isRecording = false;
                _isReplaying = true;
                _invoker.Replay();
            }
            
        }
    }
}

 

 

  InputHandler 의 Update() 안에서는 기록 중일 때만 스페이스 바 입력을 받도록 해두었다. 이 때 입력한 스페이스바 커맨드는 Invoker에 기록된다.

  InputHandler는 Playercontroller만 알고 있으면 된다. 어떻게 동작하는지는 알 필요가 없으며, 커맨드를 통해 호출된 모든 명령은 Invoker를 호출하여 실행한다.

 

  OnGUI 에는 테스트를 하기 위해서 버튼을 만들어주었다.

 

 

 

  마치며

  본문에서는 커맨드 패턴에 대해서 살펴보고, 개념을 이해하고, 예제 코드까지 작성하며 이해하는 시간을 가졌다.

 

  리플레이 기능하기 포함하여 구현했지만, 굳이 리플레이 기능을 넣지 않고도 커맨드의 캡슐화을 위해서 커맨드 패턴을 활용해도 좋다. 복잡성의 증가를 충분히 경계하고, 목적에 맞게 사용해보자.

 

  커맨드 패턴을 대체할 패턴으로는 메멘토 패턴, 큐/스택 패턴 등 다양한 패턴들이 존재한다. 진행중인 프로젝트에 알맞게 사용해보자.

  

댓글