[Перевод] Реализация паттерна проектирования

Будущих студентов курса »Unity Game Developer. Professional» приглашаем посмотреть открытый урок на тему «Продвинутый искусственный интеллект врагов в шутерах».


А сейчас делимся традиционным переводом полезного материала.

В этом туториале мы освоим паттерн проектирования «Команда» (Command) и реализуем его в Unity в рамках системы перемещения игрового объекта.

Знакомство с паттерном Команда

Запросы, приказы и команды: все мы знакомы с ними в реальной жизни; один человек отправляет запрос (или приказ, или команду) другому человеку выполнить (или не выполнять) некоторые задачи, которые ему поручены. В проектировании и разработке программного обеспечения это работает аналогичным образом: запрос одного компонента передается другому для выполнения определенных задач в рамках паттерна Команда.

Определение: Паттерн Команда — это поведенческий паттерн проектирования, в котором запрос преобразуется в объект, который инкапсулирует (содержит) всю информацию, необходимую для выполнения действия или запуска события в более позднее время. Это преобразование в объекты позволяет параметризовать методы различными запросами, задерживать выполнение запроса и/или ставить его в очередь.

Хороший и надежный программный продукт должен основываться на принципе разделения обязанностей. Обычно его можно воплотить, разбив приложение на несколько уровней (или программных компонентов). Часто встречающийся на практике пример — разделение приложения на два уровня: графический интерфейс пользователя (GUI), который отвечает только за графическую часть, и логический обработчик (logic handler), который реализует бизнес-логику.

Уровень GUI отвечает за визуализацию красивой картинки на экране и захват любых входных данных, в то время как фактические вычисления для конкретной задачи происходят на уровне логического обработчика. Таким образом, уровень GUI делегирует работу нижележащему уровню бизнес-логики.

Структура паттерна Команда 

Ниже в виде UML диаграммы классов представлена структура паттерна Команда​. Классы, входящие в диаграмму, подробно описаны ниже.

766abe18f11be1ba0c3f8adf3c8f8caf

Диаграмма классов для паттерна проектирования Команда

Для реализации паттерна Команд нам потребуются абстрактный класс 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. Вы можете применить цвет или наложить текстуру на поверхность по своему вкусу, чтобы она выглядела более привлекательно.

69762a0593c6ae3d0654181555bddcb5

Создание игрока

Теперь мы создадим игровой объект Player. В этом туториале для представления игрока мы будем использовать объект Capsule. Кликните правой кнопкой мыши в окно Hierarchy и создайте новый игровой объект Capsule. Переименуйте его в Player.

Создание скрипта GameManager.cs

Выберитеигровой объект Groundи добавьте новый скриптовый компонент. Назовите скрипт GameManager.cs.

Теперь мы реализуем перемещение объекта Player.

Для этого мы добавляем public GameObject переменную с именем player.

public GameObject mPlayer;

Теперь перетащите игровой объект Player из Hierarchy в поле Player в окне инспектора.

cc1c14bdd63f8c45ccee72fa64f648c0

Реализация движений игрока

Для перемещения игрока мы будем использовать клавиши со стрелками (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() для получения верхнего значения.

f8dafcc3e75a086c733d1bebbca0a2dd

Теперь мы реализуем класс 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».

Посмотреть открытый урок на тему «Продвинутый искусственный интеллект врагов в шутерах».

dfed938853717d09487738c2df0f88b5.jpg

ЗАБРАТЬ СКИДКУ

© Habrahabr.ru