[Перевод] Реализация паттерна проектирования
Будущих студентов курса »Unity Game Developer. Professional» приглашаем посмотреть открытый урок на тему «Продвинутый искусственный интеллект врагов в шутерах».
А сейчас делимся традиционным переводом полезного материала.
В этом туториале мы освоим паттерн проектирования «Команда» (Command) и реализуем его в Unity в рамках системы перемещения игрового объекта.
Знакомство с паттерном Команда
Запросы, приказы и команды: все мы знакомы с ними в реальной жизни; один человек отправляет запрос (или приказ, или команду) другому человеку выполнить (или не выполнять) некоторые задачи, которые ему поручены. В проектировании и разработке программного обеспечения это работает аналогичным образом: запрос одного компонента передается другому для выполнения определенных задач в рамках паттерна Команда.
Определение: Паттерн Команда — это поведенческий паттерн проектирования, в котором запрос преобразуется в объект, который инкапсулирует (содержит) всю информацию, необходимую для выполнения действия или запуска события в более позднее время. Это преобразование в объекты позволяет параметризовать методы различными запросами, задерживать выполнение запроса и/или ставить его в очередь.
Хороший и надежный программный продукт должен основываться на принципе разделения обязанностей. Обычно его можно воплотить, разбив приложение на несколько уровней (или программных компонентов). Часто встречающийся на практике пример — разделение приложения на два уровня: графический интерфейс пользователя (GUI), который отвечает только за графическую часть, и логический обработчик (logic handler), который реализует бизнес-логику.
Уровень GUI отвечает за визуализацию красивой картинки на экране и захват любых входных данных, в то время как фактические вычисления для конкретной задачи происходят на уровне логического обработчика. Таким образом, уровень GUI делегирует работу нижележащему уровню бизнес-логики.
Структура паттерна Команда
Ниже в виде UML диаграммы классов представлена структура паттерна Команда. Классы, входящие в диаграмму, подробно описаны ниже.
Диаграмма классов для паттерна проектирования Команда
Для реализации паттерна Команд нам потребуются абстрактный класс Command
, конкретные команды (ConcreteCommandN
) и классы Invoker
, Client
и Receiver
.
Command
В роли Command обычно выступает интерфейс с одним или двумя методами выполнения (Execute) и отмены (Undo) операции команды. Все классы конкретных команд должны быть производными от этого интерфейса и должны реализовывать фактический Execute и, при необходимости, реализацию Undo.
public interface ICommand
{
void Execute();
void ExecuteUndo();
}
Invoker
Класс Invoker
(также известный как Sender
) отвечает за инициирование запросов. Это класс, запускающий необходимую команду. Этот класс должен иметь переменную, в которой хранится ссылка на объект команды или его контейнер. Инвокер вместо непосредственной отправки запроса получателю запускает команду. Обратите внимание, что инвокер не несет ответственности за создание объекта команды. Обычно он получает заранее созданную команду от клиента через конструктор.
Client
Клиент (Client) создает и настраивает конкретные объекты команд. Клиент должен передать все параметры запроса, включая экземпляр получателя (Receiver), в конструктор команды. После этого результирующая команда может быть связана с одним или несколькими инвокерами. В роли клиента может служить любой класс, который создает различные объекты команд.
Receiver (опциональный класс)
Класс Receiver (получатель) — это класс, который принимает команду и содержит в основном всю бизнес-логику. Практически любой объект может выступать в качестве получателя. Большинство команд обрабатывают только детали того, как запрос передается получателю, в то время как сам получатель выполняет фактическую работу.
Конкретные команды
Конкретные команды наследуются от интерфейса Command и реализуют различные типы запросов. Конкретная команда сама по себе не должна выполнять работу, а скорее должна передавать вызов одному из объектов бизнес-логики или получателю (как описано выше). Однако в целях упрощения кода эти классы можно объединить.
Параметры, необходимые для выполнения метода на принимающем объекте, могут быть объявлены как поля в конкретной команде. Вы можете сделать объекты команд неизменяемыми (immutable), разрешив только инициализацию этих полей через конструктор.
Реализация паттерна Команда в Unity
Как уже говорилось выше, мы собираемся реализовать паттерн Команда в Unity для решения задачи перемещения игрового объекта путем применения различных типов перемещения. Каждый из этих типов перемещения будет реализован как команда. Мы также реализуем функцию отмены (Undo), чтобы иметь возможность отменять операции в обратном порядке.
Итак, начнем!
Создание нового 3D проекта Unity
Мы начнем с создания 3D проекта Unity. Назовем его CommandDesignPattern
.
Создание поверхности
Для этого урока мы создадим простой объект Plane, который будет формировать нашу поверхность для перемещения. Кликните правой кнопкой мыши окно Hierarchy и создайте новый игровой объект Plane. Переименуйте его в «Ground» и измените размер до 20 единиц по оси X и 20 единиц по оси z. Вы можете применить цвет или наложить текстуру на поверхность по своему вкусу, чтобы она выглядела более привлекательно.
Создание игрока
Теперь мы создадим игровой объект Player
. В этом туториале для представления игрока мы будем использовать объект Capsule
. Кликните правой кнопкой мыши в окно Hierarchy и создайте новый игровой объект Capsule
. Переименуйте его в Player
.
Создание скрипта GameManager.cs
Выберитеигровой объект Ground
и добавьте новый скриптовый компонент. Назовите скрипт GameManager.cs
.
Теперь мы реализуем перемещение объекта Player
.
Для этого мы добавляем public GameObject
переменную с именем player
.
public GameObject mPlayer;
Теперь перетащите игровой объект Player
из Hierarchy
в поле Player
в окне инспектора.
Реализация движений игрока
Для перемещения игрока мы будем использовать клавиши со стрелками (Up, Down, Left и Right).
Для начала реализуем движение самым простым способом. Реализовать его мы будем в методе Update
. Для простоты реализуем дискретное перемещение на 1 единицу на каждое нажатие клавиши в соответствующих направлениях.
void Update()
{
Vector3 dir = Vector3.zero;
if (Input.GetKeyDown(KeyCode.UpArrow))
dir.z = 1.0f;
else if (Input.GetKeyDown(KeyCode.DownArrow))
dir.z = -1.0f;
else if (Input.GetKeyDown(KeyCode.LeftArrow))
dir.x = -1.0f;
else if (Input.GetKeyDown(KeyCode.RightArrow))
dir.x = 1.0f;
if (dir != Vector3.zero)
{
_player.transform.position += dir;
}
}
Нажмите кнопку Play
и посмотрите, что получилось. Нажимайте клавиши со стрелками (Up, Down, Left и Right), чтобы увидеть движение игрока.
Реализация движения по клику
Теперь мы реализуем перемещение по клику правой кнопкой мыши—Player
должен будет переместиться в место на Ground
, по которому был произведен клик. Как же мы это сделаем?
Прежде всего, нам потребуются положение точки на Ground
, по которой был произведен клик правой кнопки мыши.
public Vector3? GetClickPosition()
{
if(Input.GetMouseButtonDown(1))
{
RaycastHit hitInfo;
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
if(Physics.Raycast(ray, out hitInfo))
{
//Debug.Log("Tag = " + hitInfo.collider.gameObject.tag);
return hitInfo.point;
}
}
return null;
}
Что это за возвращаемый тип Vector3?
Использование оператора ?
для возвращаемых типов в C#, например
public int? myProperty { get; set; }
означает, что тип значения со знаком вопроса является nullable типом
Nullable
типы, являются экземплярами структурыSystem.Nullable
. Тип, допускающий значениеNULL
, может представлять корректный диапазон значений для своего базового типа значения плюс дополнительное значениеNULL
. Например,Nullable<Int32>
, который произносится как«Nullable of Int32»
, может быть присвоено любое значение от -2147483648 до 2147483647, а также ему может быть присвоенnull
.Nullable<bool>
может быть присвоено значениеtrue
,false
илиnull
. Возможность назначатьnull
числовым и логическим типам особенно полезна, когда вы имеете дело с базами данных и другими типами, которые содержат элементы, которым может не быть присвоено значение. Например, логическое поле в базе данных может хранить значенияtrue
илиfalse
, либо оно может быть еще не определено.
Теперь, когда мы располагаем позицией клика, нам нужно будет реализовать функциюMoveTo
. Наша функция MoveTo
должна плавно перемещать игрока. Мы реализуем это как корутину с линейной интерполяцией вектора смещения.
public IEnumerator MoveToInSeconds(GameObject objectToMove, Vector3 end, float seconds)
{
float elapsedTime = 0;
Vector3 startingPos = objectToMove.transform.position;
end.y = startingPos.y;
while (elapsedTime < seconds)
{
objectToMove.transform.position = Vector3.Lerp(startingPos, end, (elapsedTime / seconds));
elapsedTime += Time.deltaTime;
yield return null;
}
objectToMove.transform.position = end;
}
Теперь все, что от нас требуется, это вызывать корутину всякий раз, когда происходит клик правой кнопкой мыши.
Изменим метод Update
, добавив следующие строки кода.
****
var clickPoint = GetClickPosition();
if (clickPoint != null)
{
IEnumerator moveto = MoveToInSeconds(_player, clickPoint.Value, 0.5f);
StartCoroutine(moveto);
}
****
Нажмите кнопку Play
и посмотрите, что получилось. Нажимайте клавиши со стрелками (Up, Down, Left и Right) и кликайте правой кнопкой мыши по Ground
, чтобы увидеть перемещение объекта Player
.
Реализация операции отмены
Как реализовать операцию отмены (Undo
)? Где нужна отмена движения? Попробуйте догадаться сами.
Реализация паттерна Команда в Unity
Мы собираемся реализовать метод Undo
для каждой операции перемещения, которую мы можем выполнять как с помощью нажатия клавиш, так и кликом правой кнопки мыши.
Самый простой способ реализовать операцию Undo
— использовать паттерн проектирования Команда, реализовав его в Unity.
В рамках этого паттерна мы преобразуем все типы движения в команды. Начнем с создания интерфейса Command
.
Интерфейс Command
public interface ICommand
{
void Execute();
void ExecuteUndo();
}
Наш интерфейс Command
имеет два метода. Первый — это обычный метод Execute
, а второй — метод ExecuteUndo
, выполняющий операцию отмены. Для каждой конкретной команды нам нужно будет реализовать эти два метода (помимо других методов, если они будут необходимы).
Теперь давайте преобразуем наше базовое движение в конкретную команду.
CommandMove
public class CommandMove : ICommand
{
public CommandMove(GameObject obj, Vector3 direction)
{
mGameObject = obj;
mDirection = direction;
}
public void Execute()
{
mGameObject.transform.position += mDirection;
}
public void ExecuteUndo()
{
mGameObject.transform.position -= mDirection;
}
GameObject mGameObject;
Vector3 mDirection;
}
CommandMoveTo
public class CommandMoveTo : ICommand
{
public CommandMoveTo(GameManager manager, Vector3 startPos, Vector3 destPos)
{
mGameManager = manager;
mDestination = destPos;
mStartPosition = startPos;
}
public void Execute()
{
mGameManager.MoveTo(mDestination);
}
public void ExecuteUndo()
{
mGameManager.MoveTo(mStartPosition);
}
GameManager mGameManager;
Vector3 mDestination;
Vector3 mStartPosition;
}
Обратите внимание, как реализован метод ExecuteUndo
. Он просто делает обратное тому, что делает метод Execute
.
Класс Invoker
Теперь нам нужно реализовать класс Invoker
. Помните, что Invoker
— это класс, который содержит все команды. Также помните, что для работы Undo
нам нужно будет реализовать структуру данных типа Last In First Out (LIFO)
.
Что такое LIFO? Как мы можем реализовать LIFO? Представляю вам структуру данных Stack
.
C# предоставляет особый тип коллекции, в которой элементы хранятся в стиле LIFO (Last In First Out). Эта коллекция включает в себя общий и не общий стек. Он предоставляет метод Push()
для добавления значения в верх (в качестве последнего), метод Pop()
для удаления верхнего (или последнего) значения и метод Peek()
для получения верхнего значения.
Теперь мы реализуем класс Invoker
, который будет содержать стек команд.
public class Invoker
{
public Invoker()
{
mCommands = new Stack();
}
public void Execute(ICommand command)
{
if (command != null)
{
mCommands.Push(command);
mCommands.Peek().Execute();
}
}
public void Undo()
{
if(mCommands.Count > 0)
{
mCommands.Peek().ExecuteUndo();
mCommands.Pop();
}
}
Stack mCommands;
}
Обратите внимание, как методы Execute
и Undo
реализуются инвокером. При вызове метода Execute
инвокер помещает команду в стек, вызывая метод Push
и затем выполняет метод Execute
команды. Команда сверху стека получается с помощью метода Peek
. Точно так же и при вызове
Undo инвокера вызывает метод ExecuteUndo
команды, получая верхнюю команду из стека (используя метод Peek
). После этого Invoker
удаляет верхнюю команду, с помощью метода Pop
.
Теперь мы готовы готовы использовать Invoker
и команды. Для этого мы сначала добавим новую переменную для объекта Invoker
в наш класс GameManager
.
private Invoker mInvoker;
Дальше нам нужно инициализировать объект mInvoker
в методе Start
нашего скрипта GameManager.
mInvoker = new Invoker();
Undo
Вызовем отмену мы будем нажатием клавиши U
. Добавим следующий код в метод Update
.
// Undo
if (Input.GetKeyDown(KeyCode.U))
{
mInvoker.Undo();
}
Использование команд
Теперь мы изменим метод Update
в соответствии с реализацией паттерна Команда
.
void Update()
{
Vector3 dir = Vector3.zero;
if (Input.GetKeyDown(KeyCode.UpArrow))
dir.z = 1.0f;
else if (Input.GetKeyDown(KeyCode.DownArrow))
dir.z = -1.0f;
else if (Input.GetKeyDown(KeyCode.LeftArrow))
dir.x = -1.0f;
else if (Input.GetKeyDown(KeyCode.RightArrow))
dir.x = 1.0f;
if (dir != Vector3.zero)
{
//Using command pattern implementation.
ICommand move = new CommandMove(mPlayer, dir);
mInvoker.Execute(move);
}
var clickPoint = GetClickPosition();
//Using command pattern right click moveto.
if (clickPoint != null)
{
CommandMoveTo moveto = new CommandMoveTo(
this,
mPlayer.transform.position,
clickPoint.Value);
mInvoker.Execute(moveto);
}
// Undo
if (Input.GetKeyDown(KeyCode.U))
{
mInvoker.Undo();
}
}
Нажмите кнопку Play
и посмотрите, что получилось. Нажимайте клавиши со стрелками (Up, Down, Left и Right), чтобы увидеть движение игрока, и клавишу «u» для отмены в обратном порядке.
Заключение
Паттерн проектирования Команда — это один из двадцати трех хорошо известных паттернов проектирования GoF, которые описывают, как решать повторяющиеся проблемы проектирования для разработки гибкого и реюзабельного объектно-ориентированного программного обеспечения, то есть объектов, которые легче реализовать, изменить, протестировать, повторно использовать и поддерживать.
Листинг скрипта для Unity
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GameManager : MonoBehaviour
{
public interface ICommand
{
void Execute();
void ExecuteUndo();
}
public class CommandMove : ICommand
{
public CommandMove(GameObject obj, Vector3 direction)
{
mGameObject = obj;
mDirection = direction;
}
public void Execute()
{
mGameObject.transform.position += mDirection;
}
public void ExecuteUndo()
{
mGameObject.transform.position -= mDirection;
}
GameObject mGameObject;
Vector3 mDirection;
}
public class Invoker
{
public Invoker()
{
mCommands = new Stack();
}
public void Execute(ICommand command)
{
if (command != null)
{
mCommands.Push(command);
mCommands.Peek().Execute();
}
}
public void Undo()
{
if (mCommands.Count > 0)
{
mCommands.Peek().ExecuteUndo();
mCommands.Pop();
}
}
Stack mCommands;
}
public GameObject mPlayer;
private Invoker mInvoker;
public class CommandMoveTo : ICommand
{
public CommandMoveTo(GameManager manager, Vector3 startPos, Vector3 destPos)
{
mGameManager = manager;
mDestination = destPos;
mStartPosition = startPos;
}
public void Execute()
{
mGameManager.MoveTo(mDestination);
}
public void ExecuteUndo()
{
mGameManager.MoveTo(mStartPosition);
}
GameManager mGameManager;
Vector3 mDestination;
Vector3 mStartPosition;
}
public IEnumerator MoveToInSeconds(GameObject objectToMove, Vector3 end, float seconds)
{
float elapsedTime = 0;
Vector3 startingPos = objectToMove.transform.position;
end.y = startingPos.y;
while (elapsedTime < seconds)
{
objectToMove.transform.position = Vector3.Lerp(startingPos, end, (elapsedTime / seconds));
elapsedTime += Time.deltaTime;
yield return null;
}
objectToMove.transform.position = end;
}
public Vector3? GetClickPosition()
{
if (Input.GetMouseButtonDown(1))
{
RaycastHit hitInfo;
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
if (Physics.Raycast(ray, out hitInfo))
{
//Debug.Log("Tag = " + hitInfo.collider.gameObject.tag);
return hitInfo.point;
}
}
return null;
}
// Start is called before the first frame update
void Start()
{
mInvoker = new Invoker();
}
// Update is called once per frame
void Update()
{
Vector3 dir = Vector3.zero;
if (Input.GetKeyDown(KeyCode.UpArrow))
dir.z = 1.0f;
else if (Input.GetKeyDown(KeyCode.DownArrow))
dir.z = -1.0f;
else if (Input.GetKeyDown(KeyCode.LeftArrow))
dir.x = -1.0f;
else if (Input.GetKeyDown(KeyCode.RightArrow))
dir.x = 1.0f;
if (dir != Vector3.zero)
{
//----------------------------------------------------//
//Using normal implementation.
//mPlayer.transform.position += dir;
//----------------------------------------------------//
//----------------------------------------------------//
//Using command pattern implementation.
ICommand move = new CommandMove(mPlayer, dir);
mInvoker.Execute(move);
//----------------------------------------------------//
}
var clickPoint = GetClickPosition();
//----------------------------------------------------//
//Using normal implementation for right click moveto.
//if (clickPoint != null)
//{
// IEnumerator moveto = MoveToInSeconds(mPlayer, clickPoint.Value, 0.5f);
// StartCoroutine(moveto);
//}
//----------------------------------------------------//
//----------------------------------------------------//
//Using command pattern right click moveto.
if (clickPoint != null)
{
CommandMoveTo moveto = new CommandMoveTo(this, mPlayer.transform.position, clickPoint.Value);
mInvoker.Execute(moveto);
}
//----------------------------------------------------//
//----------------------------------------------------//
// Undo
if (Input.GetKeyDown(KeyCode.U))
{
mInvoker.Undo();
}
//----------------------------------------------------//
}
public void MoveTo(Vector3 pt)
{
IEnumerator moveto = MoveToInSeconds(mPlayer, pt, 0.5f);
StartCoroutine(moveto);
}
}
Ссылки
Wikidepia Design Patterns
Wikipedia Command Design Pattern
Refactoring Guru
Game Programming Patterns
Design Patterns in Game Programming
Узнать подробнее о курсе «Unity Game Developer. Professional».
Посмотреть открытый урок на тему «Продвинутый искусственный интеллект врагов в шутерах».
ЗАБРАТЬ СКИДКУ