От переводчика
Привет!Я ненастоящий сварщик и перевод статьи дался мне тяжело, поэтому я назову его вольным — заранее очищу совесть, если где-то слишком сильно перефразировал оригинал. Буду рад указаниям на ошибки перевода, грамматики и т.п. в личку.
Перевод публикую с разрешения сайта Toptal, где выложен оригинал автора Eduardo Dias da Costa.
Обычно программисты знакомятся с профессией, начиная с Hello World. Затем ставят всё большие и большие цели и каждая новая задача приводит к важному уроку: чем больше проект, тем запутаннее код.
И в больших, и в маленьких командах никто не кодит так, как ему вздумается. Код должен быть поддерживаемым и расширяемым. Ведь компания, в которой ты работал, не обращается к тебе всякий раз, когда потребуется исправить баг или улучшить код. Да и ты вряд ли этого хочешь.
Поэтому существуют шаблоны проектирования; они — сборники правил для стандартизированного структурирования проекта, которые помогают разделить и организовать большую кодовую базу, и упростить работу с незнакомым кодом.
Эти правила, когда их придерживаются все разработчики проекта, облегчают поддержку, навигацию по старому коду и написание нового. Меньше времени уходит на планирование подхода к разработке. Но так как проблемы от случая к случаю разнятся, шаблоны — не серебряная пуля. Нужно тщательно рассматривать слабые и сильные стороны каждого, прежде чем выбрать подходящий для конкретной ситуации.
В этом обучающем материале я расскажу о своём опыте работы с популярным игровым движком 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.
Подобные проблемы часто встречаются при активном использовании наследования.
Если разбить задачи и обработчики данных на небольшие Компоненты, они могут присоединяться (attach) к Сущностям и переиспользоваться без множественного наследования, которого нет в C# и JavaScript, основных языка программирования Unity.
Где Сущность-Компонент не оправдывает ожиданий
Находясь уровнем выше ООП, EC помогает дефрагментировать и лучше организовать архитектуру кода. Однако в больших проектах мы остаёмся «слишком свободными» и в «океане возможностей» нам сложно правильно вычленить Сущности и Компоненты и организовать их взаимодействие. Существует бесконечное множество вариантов выстраивания Сущностей и Компонентов.
Один из способов избежать бардака — следовать дополнительным принципам поверх EC. Я разделяю программу на три категории:
- Одни обрабатывают сырые данные и позволяют создавать, получать, обновлять, удалять и искать их (т.е. CRUD);
- Другие реализуют интерфейсы (пользовательские, а не программные, прим. пер.) для взаимодействия с другими элементами, детектируют события, связанные с их зоной ответственности, и рассылают уведомления об этих событиях;
- Третьи отвечают за получение этих уведомлений, реализуют бизнес-логику и решают, как изменить данные.
К счастью, уже существует шаблон проектирования, описывающий это поведение.
Шаблон Модель-Представление-Контроллер (MVC)
Модель-Представление-Контроллер разделяет программу на три основных компонента: Модель (CRUD), Представление (Интерфейс/Детектирование) и Контроллер (Решение/Действие). MVC достаточно гибок и реализуется поверх EC и OOP.
В разработке игр и пользовательских интерфейсов есть обыденные средства ожидания пользовательского ввода или срабатывания триггеров, отправки уведомлений о событиях, реакции на них и обновлении данных. Эти действия наглядно показывают совместимость приложения с MVC.
Эта методология вводит ещё один слой абстракции, помогающий в планировании софта и навигации новых разработчиков даже по большим проектам. Разделение на данные, интерфейсы и бизнес-логику уменьшает количество файлов, которые разработчику придётся затронуть для добавления или изменения возможностей приложения.
Unity и EC
Теперь поближе познакомимся с возможностями Unity.
Unity — основанная на EC платформа, где Сущностями предстают инстансы GameObject
, а возможность сделать их видимыми, двигающимися и т.д. обеспечивается наследниками класса Component
.
Панель иерархии (Hierarchy Panel) и Инспектор (Inspector Panel) — мощный инструмент по сборке приложения, закрепления Компонентов за Сущностями, конфигурации их инициализирующего состояния и начальной загрузки игры. Без них потребовалось бы куда больше кода.
Панель иерархии (Hierarchy Panel) с четырьмя GameObject справа.
Инспектор (Inspector Panel) с компонентами GameObject.
Как уже обсуждалось выше, мы можем столкнуться с проблемой слишком большого количества возможностей и оказаться в гигантской иерархии с разбросанной тут и там бизнес-логикой.
Однако, MVC выручит нас: разделим Сущности по их назначению и структурируем приложение, как показано на скриншоте:
Адаптация MVC для геймдева
Пришло время для двух модификаций общего шаблона MVC, чтобы адаптировать его к ситуациям, специфичным для Unity с MVC:
- Ссылки на классы MVC должны легко пробрасываться по коду.
- Чтобы дать другому объекту доступ к инстансу, в Unity, разработчику приходится в редакторе перетаскивать (drag-n-drop) ссылки на объекты или использовать громоздкие (и медленные, прим. пер.) вызовы
GetComponent(...)
. - Если Unity крашнулась или из-за какого-то бага разорвались выстроенные в редакторе ссылки, начинается сущий Ад.
- Из-за этого необходим корневой объект, через который можно получить доступ ко всем инстансам, используемым в Приложении.
- Чтобы дать другому объекту доступ к инстансу, в Unity, разработчику приходится в редакторе перетаскивать (drag-n-drop) ссылки на объекты или использовать громоздкие (и медленные, прим. пер.) вызовы
- Некоторые элементы инкапсулируют логику, не относящуюся ни к одной из категорий MVC и часто переиспользуются. Я назову их Компонентами. Они же являются Компонентами в структуре Сущность-Компонент, но в MVС фреймворке выступают просто вспомогательными классами.
- Пример: Компонент
Rotator
, который просто поворачивает объект. Но не шлёт уведомлений, ничего не хранит и не содержит бизнес-логику.
- Пример: Компонент
Для решения этих проблем я модифицировал оригинальный шаблон и назвал его AMVCC или Приложение-Модель-Представление-Контроллер-Компонент (Application-Model-View-Controller-Component).
- Application — единая точка входа в приложение и контейнер для всех критичных инстансов и зависимых от приложения данных.
- MVC — теперь ты знаешь, что это такое :)
- Component — небольшой самодостаточный и легко переиспользуемый скрипт.
В моих проектах, этих двух нововведений хватает с лихвой.
Пример: 10 Bounces
Применим шаблон AMVCC на маленькой игре, назовём её «10 Bounces». Установки игры просты: Ball
со SphereCollider
и Rigidbody
, который начнёт падать при старте игры; Cube
в качестве земли и 5 скриптов для создания AMVCC.
Иерархия
Прежде чем приступить к коду, я сделаю набросок иерархии классов и ассетов, следуя стилю AMVCC.
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
:
BounceModel с полями bounces
и winCondition
.
После установки скриптов и запуска игры, вывод в Консоль будет таким:
Уведомления (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», а набор простых правил, которые помогают мне разделить Модель, Представление и Контроллер.
Обычно это получается само собой, когда я продумываю архитектуру или пишу скрипт.
Разделение классов
Модели
- Содержат основные данные приложения вроде здоровья или запаса патронов игрока.
- Сериализуются, десериализуются и/или конвертируются между типами.
- Загружают/сохраняют данные (локально или по Сети).
- Сообщают Контроллерам о прогрессе операции.
- Хранят состояние конечного автомата игры.
- Никогда не вызывают Представления.
Представления
- Могут получать данные из Моделей для отображения текущего состояния игры. Например, метод Представления
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
.
Улучшение иерархии классов
Как всегда, есть две стороны медали. Иногда излишнее разделение по категориям усложняет код. Только опыт поможет наилучшим образом структурировать MVC в твоём проекте.
Открыт новый навык геймдева: Unity с MVC.
Заключение
Существует множество шаблонов проектирования. В этой статье я показал наиболее полезный, основываясь на опыте последних проектов. Разработчики постоянно впитывают знания и хотят большего. Надеюсь, что это руководство помогло изучить что-то новое и вместе с тем стало ступенью развития твоего собственного стиля разработки.
Также очень рекомендую изучить другие шаблоны и поискать полезные. Начать можно со статьи в Википедии, там приведён отличный список шаблонов и их характеристик.
Если тебе понравился шаблон AMVCC, и ты хочешь протестировать его, не забудь попробовать мою библиотеку Unity MVC, которая содержит все основные классы необходимые для приложения в стиле AMVCC.
Автор: AlmazDelDiablo