유니티 커맨드 히스토리 구현

정선호·2023년 11월 29일
0

Unity Features

목록 보기
24/27

커맨드 패턴

이전에 디자인 패턴을 공부하던 도중 커맨드 패턴을 공부하면서 원리는 이해했지만 이를 어떻게 구현해야 하는지 가늠이 잡히지 않았었다.
하지만 최근에 유니티를 이용해 샌드박스 에디터를 제작할 때 커맨드 패턴을 이용해 히스토리 기능을 구현하면서 구현방식을 이해할 수 있었다.

커맨드 히스토리 명세

유니티 엔진이나 엑셀 등과 같이 한 애플리케이션 내에서 다양한 작업들이 수행될 수 있을 때 이러한 모든 작업들에 대한 기록을 하나의 커맨드 히스토리로 남겨두고 되돌리기와 다시하기를 수행할 수 있어야 한다.
요점은 모든 종류의 작업을 하나의 커맨드 히스토리로 남겨야 하는 것이다.

커맨드 패턴의 구조와 이에 대한 적용

커맨드 패턴의 구조

커맨드 패턴은 커맨드 인터페이스와 콘크리트 커맨드, 발송자와 수신자, 클라이언트로 나뉜다. 이 넷을 이해하기 쉽게 설명하면 다음과 같다.

  • 커맨드 인터페이스 : 인터페이스로써 콘크리트 커맨드가 구현하고 발송자가 수행할 함수 목록을 작성해 둔다.
  • 콘크리트 커맨드 : 인터페이스를 상속받아 수행할 작업 종류에 따라 데이터를 어떻게 처리할 지에 대한 함수 로직을 작성한다.
  • 발송자 : 커맨드들을 저장하고 커맨드 내 함수들을 발동시키는 스크립트이다.
  • 수신자 : 커맨드가 발동되면서 변화되는 점을 적용하는 대상이다.

위의 각 부분들을 유니티 터레인 에디터의 히스토리 기능으로 예시를 든다면 다음과 같이 나뉘게 된다.

  • 커맨드 인터페이스 : 터레인 에디터 히스토리 기능에서 사용될 함수 원형
  • 콘크리트 커맨드 : 터레인 에디터 히스토리에 대한 실제 구현부
  • 발송자 : 히스토리 클래스
  • 수신자 : 터레인 에디터 클래스

사진의 커맨드 패턴 다이어그램에서는 인터페이스에 execute()하나의 함수만 존재하지만, 히스토리 시스템을 구현하기 위해선 4개의 함수 원형이 필요하다.

  • 수신자에 대한 작업 수행 전의 데이터를 저장하는 SetBefore()
  • 수신자에 대한 작업 수행 후의 데이터를 저장하는 SetAfter()
  • 클라이언트가 발송자를 통해 작업 취소를 할 때 사용할 Undo()
  • 클라이언트가 발송자를 통해 작업 다시하기를 할 때 사용할 Redo()

실제 구현

커맨드 인터페이스

히스토리에서 수행할 작업들에 대한 함수 원형이 작성되어 있다.

namespace Utils.CommandHistory
{
    // 수행한 작업들의 로그 기록 및 앞으로/뒤로가기 구현용 인터페이스
    public interface ICommand
    {
        // 작업 수행 직전의 데이터를 저장하는 함수
        void SetBeforeData();
        
        // 작업 수행 직후의 데이터를 저장하는 함수
        void SetAfterData();
        
        // 작업 수행(앞으로 가기)
        void Execute();
        
        // 수행한 작업 되돌리기(뒤로가기)
        void Undo();
    }
}

콘크리트 커맨드

터레인 에디팅 중 터레인의 높낮이에 대한 히스토리를 저장하는 콘크리트 커맨드이다.
콘크리트 커맨드에는 실제 변경값, 해당 데이터를 적용할 수신자의 레퍼런스 등 함수 몸통 뿐 아니라 필요한 데이터들도 갖고 있는다.

namespace TerrainEdit
{
    public class ModifyHeightsCommand : ICommand
    {
        private class TerrainHeightState
        {
            public TerrainData data;
            public float[,] heights;
        }

        private TerrainEditor _terrainEditor;
        private List<TerrainHeightState> _startStates;
        private List<TerrainHeightState> _endStates;

        public ModifyHeightsCommand(TerrainEditor editor)
        {
            _terrainEditor = editor;
            _startStates = new List<TerrainHeightState>();
            _endStates = new List<TerrainHeightState>();
        }
        
        public void SetBeforeData()
        {
            var tData = _terrainEditor.GetCurrentModifyTerrainData();
            _startStates.Add(new TerrainHeightState()
            {
                data = tData,
                heights = tData.GetHeights(0, 0, tData.heightmapResolution, tData.heightmapResolution)
            });
        }

        public void SetAfterData()
        {
            var tData = _terrainEditor.GetModifiedTerrains();
            foreach (var data in tData)
            {
                _endStates.Add(new TerrainHeightState(){
                    data = data,
                    heights = data.GetHeights(0, 0, data.heightmapResolution, data.heightmapResolution)
                });
            }
        }

        public void Execute()
        {
            foreach (var state in _endStates)
            {
                state.data.SetHeights(0,0,state.heights);
            }
        }

        public void Undo()
        {
            foreach (var state in _startStates)
            {
                state.data.SetHeights(0,0,state.heights);
            }
        }
    }
}

수신자

수신자는 커맨드에 의해 실제로 값이 변경되는 클래스들이다. 이 상황에서는 터레인에디터이다.

namespace TerrainEdit
{
    public class TerrainEditor : MonoBehaviour
    {
        // 현재 소환된 터레인들
        public List<Terrain>        Terrains                { get; private set; }
        
        public BrushMode            BrushMode               { get { return _brushMode; } }
        public int                  BrushSize               { get { return _brushSize; } }
        public float                BrushStrength           { get { return _brushStrength; } }
        public int                  BrushIndex              { get { return _brushIndex; } }
        public int                  PaintLayerIndex         { get { return _paintLayerIndex; } }
        public int                  ObjectIndex             { get { return _objectIndex; } }
        public float                FlattenHeight           { get { return _flattenHeight; } }
        public int                  PatchSize               { get { return _terrainSize; } }
        
         ...
     }
 }

발신자

콘크리트 커맨드들을 관리하는 스크립트이다. 클라이언트는 발신자의 함수들을 이용해 히스토리를 관리한다.

namespace Utils.CommandHistory
{
    /// <summary>
    /// 기록들을 저장하여 다시하기/뒤로하기에 따라 데이터를 불러오는 클래스
    /// </summary>
    public class CommandHistory
    {
        // 최대로 저장하는 기록 수
        public static int MaxUndo = 100;
        
        private static LinkedList<ICommand> _undoHistory = new();
        private static LinkedList<ICommand> _redoHistory = new();
        
        /// <summary>
        /// 커맨드 기록을 등록하는 함수
        /// </summary>
        /// <param name="command">등록할 커맨드</param>
        public static void Register(ICommand command)
        {
            {
                if (_undoHistory.Count > MaxUndo)
                {
                    _undoHistory.RemoveLast();    
                }

                _undoHistory.AddFirst(command);
                _redoHistory.Clear();
            }
        }
        
        /// <summary>
        /// 커맨드 기록을 되돌리는 함수
        /// </summary>
        public static void Undo()
        {
            if (_undoHistory.Count > 0)
            {
                var cmd = _undoHistory.First;
                cmd.Value.Undo();

                _undoHistory.Remove(cmd);
                _redoHistory.AddFirst(cmd);
            }
        }

        /// <summary>
        /// 커맨드를 다시 수행하는 함수
        /// </summary>
        public static void Redo()
        {
            if (_redoHistory.Count > 0)
            {
                var cmd = _redoHistory.First;
                cmd.Value.Execute();

                _redoHistory.Remove(cmd);
                _undoHistory.AddFirst(cmd);
            }
        }

        /// <summary>
        /// 커맨드를 모두 삭제하는 함수
        /// </summary>
        public static void Clear()
        {
            _undoHistory.Clear();
            _redoHistory.Clear();
        }
    }
}
profile
학습한 내용을 빠르게 다시 찾기 위한 저장소

0개의 댓글