Unity и MVC: как прокачать разработку игры

в 16:11, , рубрики: C#, design patterns, Gamedev, mvc, unity3d, шаблоны проектирования

От переводчика
Привет!

Я ненастоящий сварщик и перевод статьи дался мне тяжело, поэтому я назову его вольным — заранее очищу совесть, если где-то слишком сильно перефразировал оригинал. Буду рад указаниям на ошибки перевода, грамматики и т.п. в личку.

Перевод публикую с разрешения сайта Toptal, где выложен оригинал автора Eduardo Dias da Costa.

Обычно программисты знакомятся с профессией, начиная с Hello World. Затем ставят всё большие и большие цели и каждая новая задача приводит к важному уроку: чем больше проект, тем запутаннее код.

image

И в больших, и в маленьких командах никто не кодит так, как ему вздумается. Код должен быть поддерживаемым и расширяемым. Ведь компания, в которой ты работал, не обращается к тебе всякий раз, когда потребуется исправить баг или улучшить код. Да и ты вряд ли этого хочешь.

Поэтому существуют шаблоны проектирования; они — сборники правил для стандартизированного структурирования проекта, которые помогают разделить и организовать большую кодовую базу, и упростить работу с незнакомым кодом.

image

Эти правила, когда их придерживаются все разработчики проекта, облегчают поддержку, навигацию по старому коду и написание нового. Меньше времени уходит на планирование подхода к разработке. Но так как проблемы от случая к случаю разнятся, шаблоны — не серебряная пуля. Нужно тщательно рассматривать слабые и сильные стороны каждого, прежде чем выбрать подходящий для конкретной ситуации.

В этом обучающем материале я расскажу о своём опыте работы с популярным игровым движком Unity3D и применении шаблона Модель-Представление-Контроллер (Model-View-Controller, MVC). За семь лет работы и борьбы со спагетти-кодом в игровых проектах, я добился отличной структуры кода и скорости разработки при его использовании.

Начну с разъяснения о базовой архитектуре Unity: шаблоне Сущность-Компонент (Entity-Component, EC), а затем расскажу, как поверх него строится MVC. И приведу в пример маленький проект-макет.

Мотивация

Чтобы адаптировать существующие шаблоны проектирования под конкретную задачу, программисты вынуждены изменять их. Эта свобода в программировании — доказательство, что мы не нашли единственно верной архитектуры софта. Эта статья тоже не подразумевается, как решение всех проблем. Она показывает возможности двух хорошо известных шаблонов проектирования: EC и MVC.

Шаблон Сущность-Компонент

Сущность-Компонент (EC) — шаблон проектирования, где первым делом определяется иерархия элементов, из которых состоит приложение (Сущности), а затем указываются логика и данные для каждого из них (Компоненты). В «программерских» терминах, Сущность может являться объектом с массивом из нуля и более Компонентов. Опишем её так: some-entity [component0, component1, ...]

Пример простого дерева EC:

- app [Application]
   - game [Game]
      - player [KeyboardInput, Renderer]
      - enemies
         - spider [SpiderAI, Renderer]
         - ogre [OgreAI, Renderer]
      - ui [UI]
         - hud [HUD, MouseInput, Renderer]
         - pause-menu [PauseMenu, MouseInput, Renderer]
         - victory-modal [VictoryModal, MouseInput, Renderer]
         - defeat-modal [DefeatModal, MouseInput, Renderer]

EC хороший шаблон для нивелирования проблем множественного наследования, когда запутанная структура классов может порождать проблемы вроде проблемы бриллианта (the diamond problem): класс D, наследуемых от B и C с общим классом A, может содержать конфликты из-за разного переопределения возможностей A классами B и C.

image

Подобные проблемы часто встречаются при активном использовании наследования.

Если разбить задачи и обработчики данных на небольшие Компоненты, они могут присоединяться (attach) к Сущностям и переиспользоваться без множественного наследования, которого нет в C# и JavaScript, основных языка программирования Unity.

Где Сущность-Компонент не оправдывает ожиданий

Находясь уровнем выше ООП, EC помогает дефрагментировать и лучше организовать архитектуру кода. Однако в больших проектах мы остаёмся «слишком свободными» и в «океане возможностей» нам сложно правильно вычленить Сущности и Компоненты и организовать их взаимодействие. Существует бесконечное множество вариантов выстраивания Сущностей и Компонентов.

image

Один из способов избежать бардака — следовать дополнительным принципам поверх EC. Я разделяю программу на три категории:

  • Одни обрабатывают сырые данные и позволяют создавать, получать, обновлять, удалять и искать их (т.е. CRUD);
  • Другие реализуют интерфейсы (пользовательские, а не программные, прим. пер.) для взаимодействия с другими элементами, детектируют события, связанные с их зоной ответственности, и рассылают уведомления об этих событиях;
  • Третьи отвечают за получение этих уведомлений, реализуют бизнес-логику и решают, как изменить данные.

К счастью, уже существует шаблон проектирования, описывающий это поведение.

Шаблон Модель-Представление-Контроллер (MVC)

Модель-Представление-Контроллер разделяет программу на три основных компонента: Модель (CRUD), Представление (Интерфейс/Детектирование) и Контроллер (Решение/Действие). MVC достаточно гибок и реализуется поверх EC и OOP.

В разработке игр и пользовательских интерфейсов есть обыденные средства ожидания пользовательского ввода или срабатывания триггеров, отправки уведомлений о событиях, реакции на них и обновлении данных. Эти действия наглядно показывают совместимость приложения с MVC.

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

Unity и EC

Теперь поближе познакомимся с возможностями Unity.

Unity — основанная на EC платформа, где Сущностями предстают инстансы GameObject, а возможность сделать их видимыми, двигающимися и т.д. обеспечивается наследниками класса Component.

Панель иерархии (Hierarchy Panel) и Инспектор (Inspector Panel) — мощный инструмент по сборке приложения, закрепления Компонентов за Сущностями, конфигурации их инициализирующего состояния и начальной загрузки игры. Без них потребовалось бы куда больше кода.

image

Панель иерархии (Hierarchy Panel) с четырьмя GameObject справа.

image

Инспектор (Inspector Panel) с компонентами GameObject.

Как уже обсуждалось выше, мы можем столкнуться с проблемой слишком большого количества возможностей и оказаться в гигантской иерархии с разбросанной тут и там бизнес-логикой.

Однако, MVC выручит нас: разделим Сущности по их назначению и структурируем приложение, как показано на скриншоте:

Адаптация MVC для геймдева

Пришло время для двух модификаций общего шаблона MVC, чтобы адаптировать его к ситуациям, специфичным для Unity с MVC:

  • Ссылки на классы MVC должны легко пробрасываться по коду.
    • Чтобы дать другому объекту доступ к инстансу, в Unity, разработчику приходится в редакторе перетаскивать (drag-n-drop) ссылки на объекты или использовать громоздкие (и медленные, прим. пер.) вызовы GetComponent(...).
    • Если Unity крашнулась или из-за какого-то бага разорвались выстроенные в редакторе ссылки, начинается сущий Ад.
    • Из-за этого необходим корневой объект, через который можно получить доступ ко всем инстансам, используемым в Приложении.
  • Некоторые элементы инкапсулируют логику, не относящуюся ни к одной из категорий MVC и часто переиспользуются. Я назову их Компонентами. Они же являются Компонентами в структуре Сущность-Компонент, но в MVС фреймворке выступают просто вспомогательными классами.
    • Пример: Компонент Rotator, который просто поворачивает объект. Но не шлёт уведомлений, ничего не хранит и не содержит бизнес-логику.

Для решения этих проблем я модифицировал оригинальный шаблон и назвал его AMVCC или Приложение-Модель-Представление-Контроллер-Компонент (Application-Model-View-Controller-Component).
image

  • Application — единая точка входа в приложение и контейнер для всех критичных инстансов и зависимых от приложения данных.
  • MVC — теперь ты знаешь, что это такое :)
  • Component — небольшой самодостаточный и легко переиспользуемый скрипт.

В моих проектах, этих двух нововведений хватает с лихвой.

Пример: 10 Bounces

Применим шаблон AMVCC на маленькой игре, назовём её «10 Bounces». Установки игры просты: Ball со SphereCollider и Rigidbody, который начнёт падать при старте игры; Cube в качестве земли и 5 скриптов для создания AMVCC.

Иерархия

Прежде чем приступить к коду, я сделаю набросок иерархии классов и ассетов, следуя стилю AMVCC.
image

GameObject view содержит все визуальные элементы и View скрипты. Объекты model и controller в маленьких проектах обычно содержат только один соответствующий скрипт, а в проектах побольше в них будет множество скриптов, ответственных за конкретные действия, данные и т.д.

Когда кто-нибудь хочет получить доступ к:

  • Данным: последует в application > model > ...
  • Логике/Процессу работы (workflow): application > controller > ...
  • Рендер/Интерфейс/Детектирование: application > view > ...
    Если все команды будут следовать этим трём правилам, старые (legasy) проекты не будут проблемой.

Обрати внимание, что в иерархии нет отдельного контейнера для Component, так как они могут независимо использоваться разными элементами.

Написание скриптов

Обрати внимание: скрипты, показанные ниже — обобщение реального кода. Если хочешь изучить вопрос подробнее, вот ссылка на мой MVC фреймворк Unity MVC. Он структурирован под AMVCC, поэтому там ты найдёшь базовые классы, необходимые в большинстве приложений.

Теперь взглянем на структуру скриптов «10 Bounces».

Для незнакомых с устройством Unity, я кратко поясню, как взаимодействуют GameObjects. «Компоненты» из шаблона Сущность-Компонент представлены классом MonoBehaviour. Чтобы он стал доступен во время работы приложения, разработчик должен в редакторе перетащить (drag-n-drop) исходный файл на GameObject (который является «Сущностью» в шаблоне EC) или использовать команду AddComponent<YourMonobehaviour>(). После этого скрипт будет инстанцирован и готов к использованию.

Объявим два класса.

Application («A» в AMVCC) — основной класс, у которого будет только один инстанс, содержащий ссылки на все инстанцированные в игре элементы. Внутри объявим три публичные переменные: model, view и controller, которые обеспечат доступ к корневым объектам MVC.

Element — вспомогательный базовый класс, дающий дочерним инстансам MVC доступ к Application.

Помни, что оба класса — наследники MonoBehaviour. Они — «Компоненты», прикреплённые к GameObject «Сущностям».

// BounceApplication.cs

// Base class for all elements in this application.
public class BounceElement : MonoBehaviour
{
   // Gives access to the application and all instances.
   public BounceApplication app { get { return GameObject.FindObjectOfType<BounceApplication>(); }}
}

// 10 Bounces Entry Point.
public class BounceApplication : MonoBehaviour
{
   // Reference to the root instances of the MVC.
   public BounceModel model;
   public BounceView view;
   public BounceController controller;

   // Init things here
   void Start() { }
}

Отнаследуем от BounceElement базовые классы MVC. Обычно BounceModel, BounceView и BounceController выступают контейнерами специализированных MVC объектов, но в упрощённом примере только у Представления будет вложенная структура. Модели и Контроллеру хватит по одному скрипту.

// BounceModel.cs

// Contains all data related to the app.
public class BounceModel : BounceElement
{
   // Data
   public int bounces;  
   public int winCondition;
}

// BounceView .cs

// Contains all views related to the app.
public class BounceView : BounceElement
{
   // Reference to the ball
   public BallView ball;
}

// BallView.cs

// Describes the Ball view and its features.
public class BallView : BounceElement
{
   // Only this is necessary. Physics is doing the rest of work.
   // Callback called upon collision.
   void OnCollisionEnter() { app.controller.OnBallGroundHit(); }
}

// BounceController.cs

// Controls the app workflow.
public class BounceController : BounceElement
{
   // Handles the ball hit event
   public void OnBallGroundHit()
   {
      app.model.bounces++;
      Debug.Log(“Bounce ”+app.model.bounce);
      if(app.model.bounces >= app.model.winCondition)
      {
         app.view.ball.enabled = false;
         app.view.ball.GetComponent<RigidBody>().isKinematic=true; // stops the ball
         OnGameComplete();
      } 
   }

   // Handles the win condition
   public void OnGameComplete() { Debug.Log(“Victory!!”); }
}

Все скрипты созданы, теперь можно закрепить их за GameObjects и сконфигурировать.

Иерархия будет такой:

- application [BounceApplication]
    - model [BounceModel]
    - controller [BounceController]
    - view [BounceView]
        - ...
        - ball [BallView]
        - ...

Рассмотрим, как это выглядит в редакторе Unity на примере BounceModel:
image
BounceModel с полями bounces и winCondition.

После установки скриптов и запуска игры, вывод в Консоль будет таким:
image

Уведомления (notifications)

Когда шар ударяется о землю, его представление вызывает метод app.controller.OnBallGroundHit(). Нельзя сказать, что это «неправильный» способ слать уведомления в приложении, но по моему опыту куда удобнее использовать простую систему нотификаций, реализованной в классе Application.

Обновим BounceApplication:

// BounceApplication.cs

class BounceApplication 
{
   // Iterates all Controllers and delegates the notification data
   // This method can easily be found because every class is “BounceElement” and has an “app” 
   // instance.
   public void Notify(string p_event_path, Object p_target, params object[] p_data)
   {
      BounceController[] controller_list = GetAllControllers();
      foreach(BounceController c in controller_list)
      {
         c.OnNotification(p_event_path,p_target,p_data);
      }
   }

   // Fetches all scene Controllers.
   public BounceController[] GetAllControllers() { /* ... */ }
}

Теперь понадобится новый скрипт, где разработчики будут указывать имена событий, уведомления о которых могут прийти:

// BounceNotifications.cs

// This class will give static access to the events strings.
class BounceNotification
{
   static public string BallHitGround = “ball.hit.ground”;
   static public string GameComplete  = “game.complete”;
   /* ...  */
   static public string GameStart     = “game.start”;
   static public string SceneLoad     = “scene.load”;
   /* ... */
}

Благодаря этому, разработчик сможет открыть один файл и понять общее поведение приложения, вместо того, чтобы искать по коду методы вроде controller.OnSomethingComplexName.

Теперь адаптируем BallView и BounceController для работы с новой системой.

// BallView.cs

// Describes the Ball view and its features.
public class BallView : BounceElement
{
   // Only this is necessary. Physics is doing the rest of work.
   // Callback called upon collision.
   void OnCollisionEnter() { app.Notify(BounceNotification.BallHitGround,this); }
}

// BounceController.cs

// Controls the app workflow.
public class BounceController : BounceElement
{
   // Handles the ball hit event
   public void OnNotification(string p_event_path,Object p_target,params object[] p_data)
   {
      switch(p_event_path)
      {
         case BounceNotification.BallHitGround:
            app.model.bounces++;
            Debug.Log(“Bounce ”+app.model.bounce);
            if(app.model.bounces >= app.model.winCondition)
            {
               app.view.ball.enabled = false;
               app.view.ball.GetComponent<RigidBody>().isKinematic=true; // stops the ball
               // Notify itself and other controllers possibly interested in the event
               app.Notify(BounceNotification.GameComplete,this);            
            }
         break;

         case BounceNotification.GameComplete:
            Debug.Log(“Victory!!”);
         break;
      } 
   }
}

В проектах побольше может быть много уведомлений. Чтобы избавиться от огромных switch-case, целесообразно создавать специализированные контроллеры и обрабатывать в них разные группы уведомлений.

AMVCC в реальном мире

«10 Bounces» показывает простейший вариант использования шаблона AMVCC. Чтобы использовать его на практике, придётся оттачивать мышление в рамках трёх категорий MVC и учиться визуализировать сущности в виде упорядоченной иерархии.

В больших проектах разработчики сталкиваются с более сложными сценариями и часто сомневаются, является та или иная сущность Представлением или Контроллером, а может стоит разделить класс на части.

Практические правила (от Эдуардо)

Это не «Универсальный гид по организации MVC», а набор простых правил, которые помогают мне разделить Модель, Представление и Контроллер.

Обычно это получается само собой, когда я продумываю архитектуру или пишу скрипт.

Разделение классов

Модели

  • Содержат основные данные приложения вроде здоровья или запаса патронов игрока.
  • Сериализуются, десериализуются и/или конвертируются между типами.
  • Загружают/сохраняют данные (локально или по Сети).
  • Сообщают Контроллерам о прогрессе операции.
  • Хранят состояние конечного автомата игры.
  • Никогда не вызывают Представления.

Представления

  • Могут получать данные из Моделей для отображения текущего состояния игры. Например, метод Представления player.Run() может использовать model.speed для наглядного отображения способностей игрока.
  • Никогда не изменяет Модели.
  • Строго следует назначению класса. Например:
    • PlayerView никогда не детектирует ввод или изменение состояния игры.
    • Представление работает как чёрный ящик с интерфейсом и оповещает о важных событиях.
    • Не хранит важных данных вроде скорости, здоровья, жизней и так далее.

Контроллеры

  • Не хранят основные данные игры.
  • Могут фильтровать уведомления от Представлений.
  • Обновляют и используют данные Моделей.
  • Управляют действиями на сцене Unity.

Иерархия классов

Я понимаю, какие классы нуждаются в разделении, когда в переменных появляется слишком много префиксов или явно прослеживается возможность ветвления (вроде классов Player в MMO или видов Gun в FPS). Но для полноты статьи я не мог обойти этот момент стороной.

Например, Модель с данными игроков имеет множество переменных playerDataA, playerDataB ,… или Контроллер, обрабатывающий уведомления игрока, — методы OnPlayerDidA, OnPlayerDidB,… Мы хотим уменьшить количество кода и избавиться от префиксов player и OnPlayer. Так как Модель проще для понимания, я продемонстрирую это на её примере.

Я обычно начинаю с единственной Модели, которая содержит все данные игры:

// Model.cs

class Model
{
   public float playerHealth;
   public int playerLives;

   public GameObject playerGunPrefabA;
   public int playerGunAmmoA;

   public GameObject playerGunPrefabB;
   public int playerGunAmmoB;

   // Ops Gun[C D E ...] will appear...
   /* ... */

   public float gameSpeed;
   public int gameLevel;
}

Но чем сложнее игра, тем больше будет переменных. При достаточной сложности, мы в конце концов получим гигантский класс, содержащий переменные model.playerABCDFoo. Вложенные элементы упростят код и дадут возможность переключаться между вариациями данных.

// Model.cs

class Model
{
   public PlayerModel player;  // Container of the Player data.
   public GameModel game;      // Container of the Game data.
}

// GameModel.cs

class GameModel
{
   public float speed;         // Game running speed (influencing the difficulty)
   public int level;           // Current game level/stage loaded
}

// PlayerModel.cs

class PlayerModel
{
   public float health;        // Player health from 0.0 to 1.0.
   public int lives;           // Player “retry” count after he dies.
   public GunModel[] guns;     // Now a Player can have an array of guns to switch ingame.
}

// GunModel.cs

class GunModel
{
   public GunType type;        // Enumeration of Gun types.
   public GameObject prefab;   // Template of the 3D Asset of the weapon.
   public int ammo;            // Current number of bullets
   public int clips;           // Number of reloads possible
}

При такой организации классов, за раз разработчик рассматривает одну логическую единицу, что упрощает понимание кода. Давай представим шутер от первого лица с множеством наименований оружия. Данные в классе GunModel, позволяют создать набор префабов для каждого вида оружия и затем использовать их в игре. Префаб — заранее подготовленный GameObject, готовый к быстрому копированию и переиспользованию.

А если информация об оружии содержится в одном классе в переменных вроде gun0Ammo, gun1Ammo, gun0Clips и т.д., тогда, столкнувшись с необходимостью сохранить данные Gun, понадобится сохранить Model целиком, включая нежелательные данные о Player. В этом случае очевидно, что будет лучше создать новый класс GunModel.
image
Улучшение иерархии классов

Как всегда, есть две стороны медали. Иногда излишнее разделение по категориям усложняет код. Только опыт поможет наилучшим образом структурировать MVC в твоём проекте.

Открыт новый навык геймдева: Unity с MVC.

Заключение

Существует множество шаблонов проектирования. В этой статье я показал наиболее полезный, основываясь на опыте последних проектов. Разработчики постоянно впитывают знания и хотят большего. Надеюсь, что это руководство помогло изучить что-то новое и вместе с тем стало ступенью развития твоего собственного стиля разработки.

Также очень рекомендую изучить другие шаблоны и поискать полезные. Начать можно со статьи в Википедии, там приведён отличный список шаблонов и их характеристик.

Если тебе понравился шаблон AMVCC, и ты хочешь протестировать его, не забудь попробовать мою библиотеку Unity MVC, которая содержит все основные классы необходимые для приложения в стиле AMVCC.

Автор: AlmazDelDiablo

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js