Давно я ничего не писал, пора подать какие-нибудь признаки жизни. Так как я сам сейчас осваиваю паттерны проектирования, то давайте и коснемся этой темы. Рассматривать паттерны мы будем в срезе игрового проектирования. Сегодня мы рассмотрим паттерн «Команда» (Command), который позволяет представить команду в виде объекта.
Описание паттерна можно почитать тут — или в любом другом месте в Интернете. Этот паттерн особенно полезен в играх, где нужно реализовать отложенное выполнение команд (например, постановка в очередь производства юнитов в стратегиях), отмену команд в том числе и уже выполненных (то самое undo/redo), свободное переназначение команд на кнопки клавиатуры/геймпада, при сетевом взаимодействии и т.д..
Итак, задача у нас будет следующая. С помощью стрелок на клавиатуре, мы будем управлять кубиком. Управление должно быть реализовано с помощью паттерна «Команды», и поддерживать операции undo/redo. Вот и вся задача. Этого достаточно, чтоб понять суть паттерна и его область применения, а так же заморочиться на полдня, если вы делаете это впервые. 🙂
Подготовка сцены.
Я выполнять пример буду в Unity. Для начала подготовим сцену. Создаем плейн (он будет полом), кубик (он будет плеером), и две кнопки (собственно undo/redo). Размеры, цвета, расположения выбираем по вкусу, это не принципиально. У меня сцена выглядит так:
Далее создаем папку Scripts, а в ней папки CommandController и PlayerController.
Скриптуем кубик.
В папке PlayerController создаем скрипт Player и вешаем на кубик, он будет управлять нашим кубиком. Я сразу подумал, что для наглядности работы undo/redo нужно, чтоб кубик передвигался не плавно, а скачкообразно, пошагово – нажал кнопку и кубик сдвинулся на 15 единиц, например. Поэтому, в скрипте я первым делом прописал поле, чтоб из инспектора можно было регулировать такой шаг:
Далее были написаны простейшие методы для манипулирования кубиком:
public void MoveUp()
{
transform.position = new Vector3(transform.position.x + Step, transform.position.y, transform.position.z);
}
public void MoveDown()
{
transform.position = new Vector3(transform.position.x - Step, transform.position.y, transform.position.z);
}
public void MoveLeft()
{
transform.position = new Vector3(transform.position.x, transform.position.y, transform.position.z + Step);
}
public void MoveRight()
{
transform.position = new Vector3(transform.position.x, transform.position.y, transform.position.z - Step);
}
Никаких заморочек, мы просто прибавляем/отнимаем наш Step к нужной оси. Кстати, на данном этапе не понятно почему методы публичные, это станет понятно позже. Теперь в методе Update(), в зависимости от нажатой клавиши вызываем нужный метод:
private void Update ()
{
if (Input.GetKeyDown(KeyCode.UpArrow))
MoveUp();
if (Input.GetKeyDown(KeyCode.DownArrow))
MoveDown();
if (Input.GetKeyDown(KeyCode.LeftArrow))
MoveLeft();
if (Input.GetKeyDown(KeyCode.RightArrow))
MoveRight();
}
Кубик бегает, как и задумывалось, подстраиваем Step по вкусу, и приступаем к самому интересному.
Интерфейс ICommand
Ядром паттерна является интерфейс ICommand (так же часто вместо интерфейса используют абстрактный класс, но мне в данном случае не нужна частичная реализация, т.ч. логичнее будет воспользоваться интерфейсом). Интерфейс должен предоставлять методы работы с командой, т.е.: выполнить; отменить; повторить и т.п..
В папке CommandController я создал файл ICommand и написал в нем следующее:
public interface ICommand
{
void Execute();
void UnExecute();
void ReExecute();
}
Кстати, если вы создадите скрипт через юнити, у вас получится типичный скрипт юнити – класс, наследуемый от MonoBehaviour, в этом случае вам нужно очистить все содержимое скрипта кроме неймспейса, или пересоздать файл с помощью вашей IDE.
Теперь все наши команды, должны наследовать этот интерфейс и реализовывать его методы.
Реализуем интерфейс плеером.
Сейчас у нас имеется только кубик-плеер, но в реальном проекте, у нас может быть куча сущностей с отличным набором команд. Например, кроме юнита, может быть здание, со своим набором команд и тд.. Для того чтоб команды не находились в одной куче, я и создал отдельную папку PlayerController, в нее мы и будем помещать классы-команды нашего кубика, реализующие интерфейс ICommand.
Не большое отступление. Пытливый ум может задаться вопросом, а если у нас имеется например, сущность танк, он ведь тоже может передвигаться аналогично кубику, неужели придется дублировать для него реализацию интерфейса, а так же и для любых других сущностей умеющих передвигаться? Нет, не придется. Ситуацию можно будет разрулить с помощью все тех же интерфейсов и наследования, а «общий функционал» вынести в «общую папку». В общем способы есть, и не скажу, что сейчас я их сам все знаю. Да и не важно это сейчас.
Итак, начнем реализовывать команды. Все 4 команды реализуются идентичным образом, так что я опишу процесс на примере только одной команды MoveUp. В папке PlayerController создаем класс MoveUp. По итогу он будет выглядеть так:
internal class MoveUp : ICommand
{
private readonly Player _player;
private readonly Vector3 _previusPos;
public MoveUp(Player player)
{
this._player = player;
this._previusPos = player.transform.position;
}
public void Execute()
{
_player.MoveUp();
}
public void UnExecute()
{
_player.transform.position = _previusPos;
}
public void ReExecute()
{
Execute();
}
}
В конструктор класса будет передаваться ссылка на плеера, в самом конструкторе инициализируются поля: ссылка на плеера и текущая позиция плеера (на момент создания объекта-команды). Последнее поле, как некоторые из вас уже догадались, необходимо на случай, если данная команда будет отменена. Дальше идет реализация 3х методов интерфейса: Execute() – вызываем метод передвижения вверх объекта-плеера; UnExecute() – отменяем текущую данную команду, возвращая плеера в предшествующую позицию; ReExecute() – повторяем данную операцию. Остальные команды реализуются так же, отличия только в вызываемом методе внутри Execute(), и названиях класса и конструктора соответственно.
Теперь нам нужен скрипт, который будет формировать очередь команд, и работать с ней.
CommandManager
В папке CommandController я создал новый класс CommandManager. Для начала нам нужно три поля и конструктор по умолчанию, инициализирующий их:
private List<ICommand> _commands;
private int _currentCom;
private int _targetCom; //"Отсечка"
public CommandManager()
{
_commands = new List<ICommand>();
_currentCom = -1;
_targetCom = -1;
}
В списке будут храниться все наши команды (для самых маленьких: как видите, благодаря тому, что все команды наследуются от одного интерфейса, мы можем использовать его, как тип для списка, и он примет любую команду. Все методы, объявленные в интерфейсе можно будет вызывать без каких-либо приведений типов). Переменная _currentCom указывает на последнюю выполненную команду. Переменная _targetCom своеобразная «отсечка», задающая до какой позиции в списке, следует выполнять команды. В нашем случае, это нужно для «отрезания» той части, которая останется впереди после отмен.
В классе нам нужно реализовать всего 4 метода: добавление команды в очередь, метод выполнения следующей команды и методы отмены/повтора команды.
Метод добавления команды в список:
public void SetCommand(ICommand command)
{
_ClearCommands();
_commands.Add(command);
_targetCom = _commands.Count - 1;
}
Ах да, забыл про метод _ClearCommands, это закрытый метод для очистки «отсеченных» команд. Например, у нас выполнилось 50 команд, 20 из них мы отменили, теперь мы можем либо повторно их выполнить с помощью redo, либо задать новые команды. В случае задания новых команд, нам нужно удалить те 20 отмененных команд, иначе у нас начнется не вполне логическое для рядового пользователя поведение, при последующем вызове undo/redo.
Метод выглядит так:
private void _ClearCommands()
{
int lastIndex = _commands.Count - 1;
if (_targetCom < lastIndex)
_commands.RemoveRange(_targetCom + 1, lastIndex - _targetCom);
}
Проверяем, не находимся ли мы в конце списка и, если нет — удаляем все, начиная со следующей позиции.
Далее идет метод выполняющий следующую команду:
public bool ExecuteCommand()
{
if (_commands.Count > 0 && _currentCom != _targetCom)
{
_currentCom++;
_commands[_currentCom].Execute();
return true;
}
return false;
}
Метод возвращает булевое значение, в зависимости от того, выполнил он команду или достиг конца списка/отсечки. Зачем это нужно, станет понятно чуть позже. В методе мы проверяем, есть ли в списке хоть одна команда и не достигли ли мы «отсечки». Увеличиваем счетчик текущей команды (сдвигаем «каретку» вперед), и вызываем метод Execute() на элементе списка под «кареткой».
Метод отмены:
public bool UndoCommand()
{
if (_commands.Count > 0 && _currentCom > -1)
{
_commands[_currentCom].UnExecute();
_currentCom--;
_targetCom = _currentCom;
}
return _currentCom > -1;
}
Этот метод также возвращает булевое значение в зависимости от того, уперлась ли «каретка» в начало списка или нет. Если у нас имеются команды, и мы не в самом начале списка, значит у нас есть что отменить, поэтому вызываем на текущем элементе списка метод UnExecute(). Сдвигаем «каретку» на одну позицию назад, и устанавливаем на ее же позицию «отсечку».
И наконец, последний метод класса:
public bool RedoCommand()
{
if (_commands.Count > 0 && _currentCom < _commands.Count -1)
{
_currentCom++;
_commands[_currentCom].ReExecute();
_targetCom = _currentCom;
}
return _currentCom != _commands.Count - 1;
}
Метод возвращает бул в зависимости от того, уперлась ли «каретка» в конец списка. Если у нас имеются команды, и мы не в самом конце списка, значит у нас имеются отмененные команды, которые можно выполнить повторно. Поэтому переводим «каретку» вперед и вызываем на элементе списка под «кареткой» метод ReExecute(). Устанавливаем «отсечку» на позицию «каретки».
Теперь нам надо как-то порождать и передавать команды, в этот класс.
Точка входа.
Для начала, необходимо где-то создать объект класса CommandManager, и по-хорошему бы он должен быть один единственный в нашей игре т.е. его нужно создать по принципу синглтона, либо вообще сделать класс статичным. Ну да ладно, сейчас это не важно. Я не стал долго думать, и решил сделать некоторый инициализирующий скрипт, такая себе «точка входа», где будут порождаться и храниться необходимые объекты. Скрипт я повесил на камеру. Код скрипта очень простой:
public class InitScript : MonoBehaviour
{
private CommandManager _commandManager;
internal CommandManager CommandManager
{
get
{
return _commandManager;
}
}
// Use this for initialization
private void Start ()
{
this._commandManager = new CommandManager();
}
}
Порождение команд и постановка в очередь.
В скрипте Player делаем некоторые изменения. Добавляем поле-ссылку на менеджер:
private CommandController.CommandManager _commandManager;
и инициализируем его в методе Start():
private void Start ()
{
this._commandManager = GameObject.Find("Main Camera").GetComponent<InitScript>().CommandManager;
}
Переписываем метод Update() так:
private void Update ()
{
if (Input.GetKeyDown(KeyCode.UpArrow))
_commandManager.SetCommand(new MoveUp(this));
if (Input.GetKeyDown(KeyCode.DownArrow))
_commandManager.SetCommand(new MoveDown(this));
if (Input.GetKeyDown(KeyCode.LeftArrow))
_commandManager.SetCommand(new MoveLeft(this));
if (Input.GetKeyDown(KeyCode.RightArrow))
_commandManager.SetCommand(new MoveRight(this));
}
Всё. Команды создаются и становятся в очередь, но… не выполняются 🙂 И правильно, ведь выполнение никто не инициирует, нам нужен еще один скрипт – инициатор (Invoker).
CommandController
В качестве инициатора у меня выступает скрипт CommandController, который создан в одноименной папке (у меня туго с фантазией, поэтому названия такие, какие есть). Скрипт прилинкован к камере. Именно CommandController должен определять логику вызова команд: например, выполнять их с паузами между командами, или по нажатию отдельной кнопки или еще как. У нас будет все просто, команды будут выполнятся потоком, как они поступают, так тут же и выполняются.
Вызов команд будет производиться в методе Update():
private void Update ()
{
GetComponent<InitScript>().CommandManager.ExecuteCommand();
}
Так же создаем два метода для кнопок undo/redo:
public void btn_Undo()
{
GetComponent<InitScript>().CommandManager.UndoCommand();
}
public void btn_Redo()
{
GetComponent<InitScript>().CommandManager.RedoCommand();
}
Не правильно конечно создавать обработчики кнопок в этом скрипте, но мне под конец уже лениво было делать лишний скрипт, чтоб сделать простое делегирование, для примера и так сойдет.
Теперь нужно привязать наши кнопки к обработчикам, для этого выделяем кнопку в иерархии и выполняем три действия:
- Жмем плюсик в инспекторе в разделе “On Click()”;
- В появившееся пустое поле объекта, перетаскиваем объект со скриптом, в котором реализованы обработчики, в нашем случае это камера.
- В выпадающем списке функций ищем скрипт с обработчиками, и выбираем нужный метод (например, btn_Undo()).
Так же и со второй кнопкой.
Вот и всё, поставленная задача выполнена, кубик бегает, undo/redo работает как задумано.
Маленький бонус.
Мне захотелось, чтоб кнопки undo/redo меняли свою активность в зависимости от того, есть ли что отменять/повторять соответственно, как это сделано в большинстве приложений. Именно для этого мы и предусмотрели возврат булевого значения, методами класса CommandManager. Изменим наш CommandController следующим образом:
public Button UndoButton;
public Button RedoButton;
private void Start()
{
//Изначально кнопки неактивны
UndoButton.interactable = false;
RedoButton.interactable = false;
}
// Update is called once per frame
private void Update()
{
if (GetComponent<InitScript>().CommandManager.ExecuteCommand())
{
UndoButton.interactable = true;
RedoButton.interactable = false;
}
}
public void btn_Undo()
{
UndoButton.interactable = GetComponent<InitScript>().CommandManager.UndoCommand();
RedoButton.interactable = true;
}
public void btn_Redo()
{
RedoButton.interactable = GetComponent<InitScript>().CommandManager.RedoCommand();
UndoButton.interactable = true;
}
Как работает этот код, объяснять не буду, попробуйте разобраться самостоятельно – тут все просто. Главное не забудьте перетащить кнопки в инспекторе, в соответствующие поля. И да, еще раз повторюсь, реализовывать подобную логику правильнее будет в отдельно скрипте под обработчики событий кнопок.
На этом всё.
P.S. Для самых ленивых залил проект-пример сюда.