Unity с MVC: как повысить уровень разработки игр
Опубликовано: 2022-03-11Программисты-новички обычно начинают изучать ремесло с классической программы Hello World
. Оттуда обязательно последуют все более и более масштабные задания. Каждая новая задача преподносит важный урок:
Чем больше проект, тем больше спагетти.
Вскоре становится ясно, что в больших или малых командах нельзя безрассудно делать то, что хочется. Код должен поддерживаться и может сохраняться в течение длительного времени. Компании, в которых вы работали, не могут просто найти вашу контактную информацию и спрашивать вас каждый раз, когда они хотят исправить или улучшить кодовую базу (и вы тоже не хотите, чтобы они это делали).
Вот почему существуют шаблоны проектирования программного обеспечения; они налагают простые правила, определяющие общую структуру программного проекта. Они помогают одному или нескольким программистам отделить основные части большого проекта и организовать их стандартизированным образом, устраняя путаницу при обнаружении какой-либо незнакомой части кодовой базы.
Эти правила, если их соблюдать все, позволяют лучше поддерживать устаревший код и навигацию по нему, а новый код добавлять быстрее. Меньше времени тратится на планирование методологии разработки. Поскольку проблемы не бывают одного вида, не существует шаблона проектирования «серебряная пуля». Необходимо тщательно рассмотреть сильные и слабые стороны каждого шаблона и найти наиболее подходящий для решения поставленной задачи.
В этом руководстве я расскажу о своем опыте работы с популярной платформой разработки игр Unity и шаблоном Model-View-Controller (MVC) для разработки игр. За семь лет разработки, боровшись со своей справедливой долей спагетти разработчиков игр, я добился отличной структуры кода и скорости разработки, используя этот шаблон проектирования.
Я начну с небольшого объяснения базовой архитектуры Unity, шаблона Entity-Component. Затем я перейду к объяснению того, как MVC сочетается с ним, и приведу в качестве примера небольшой фиктивный проект.
Мотивация
В литературе по программному обеспечению мы найдем множество шаблонов проектирования. Несмотря на то, что у них есть набор правил, разработчики обычно немного нарушают правила, чтобы лучше адаптировать шаблон к своей конкретной проблеме.
Эта «свобода программирования» является доказательством того, что мы еще не нашли единого окончательного метода разработки программного обеспечения. Таким образом, эта статья не предназначена для окончательного решения вашей проблемы, а скорее для демонстрации преимуществ и возможностей двух хорошо известных шаблонов: Entity-Component и Model-View-Controller.
Шаблон Entity-Component
Entity-Component (EC) — это шаблон проектирования, в котором мы сначала определяем иерархию элементов, составляющих приложение (сущности), а затем мы определяем функции и данные, которые каждый из них будет содержать (компоненты). Говоря более «программистскими» терминами, Entity может быть объектом с массивом из 0 или более компонентов. Давайте изобразим Entity следующим образом:
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 — хороший шаблон для решения проблем множественного наследования, когда сложная структура класса может привести к проблемам, таким как проблема алмаза, когда класс D, наследующий два класса, B и C, с одним и тем же базовым классом A, может привести к конфликтам, потому что как B и C изменяют свойства A по-разному.
Такого рода проблемы могут быть распространены при разработке игр, где часто широко используется наследование.
Разбивая функции и обработчики данных на более мелкие компоненты, их можно присоединять и повторно использовать в разных сущностях, не полагаясь на множественное наследование (которое, кстати, даже не является функцией C# или Javascript, основных языков, используемых Unity). ).
Где Entity-Component терпит неудачу
Будучи на один уровень выше ООП, EC помогает дефрагментировать и лучше организовать архитектуру вашего кода. Однако в больших проектах мы все еще «слишком свободны» и можем оказаться в «океане функций», с трудом находя нужные Сущности и Компоненты или выясняя, как они должны взаимодействовать. Существует бесконечное количество способов сборки Сущностей и Компонентов для данной задачи.
Один из способов избежать беспорядка — наложить некоторые дополнительные рекомендации поверх Entity-Component. Например, мне нравится думать о программном обеспечении, разделяя его на три разные категории:
- Некоторые обрабатывают необработанные данные, позволяя создавать, читать, обновлять, удалять или искать их (т. е. концепция CRUD).
- Другие реализуют интерфейс для взаимодействия с другими элементами, обнаруживая события, связанные с их областью действия, и инициируя уведомления, когда они происходят.
- Наконец, некоторые элементы отвечают за получение этих уведомлений, принятие бизнес-логических решений и принятие решений о том, как следует манипулировать данными.
К счастью, у нас уже есть шаблон, который ведет себя точно так же.
Шаблон модель-представление-контроллер (MVC)
Шаблон Model-View-Controller (MVC) разделяет программное обеспечение на три основных компонента: модели (данные CRUD), представления (интерфейс/обнаружение) и контроллеры (решение/действие). MVC достаточно гибок, чтобы его можно было реализовать даже поверх ECS или ООП.
При разработке игр и пользовательского интерфейса используется обычный рабочий процесс: ожидание ввода пользователя или другого условия запуска, отправка уведомлений об этих событиях в подходящее место, принятие решения о том, что делать в ответ, и соответствующее обновление данных. Эти действия наглядно показывают совместимость этих приложений с MVC.
Эта методология вводит еще один уровень абстракции, который поможет в планировании программного обеспечения, а также позволит новым программистам ориентироваться даже в большой кодовой базе. Разделив мыслительный процесс на данные, интерфейс и решения, разработчики могут сократить количество исходных файлов, которые необходимо просмотреть, чтобы добавить или исправить функциональность.
Единство и ЕС
Давайте сначала подробнее рассмотрим, что нам дает Unity.
Unity — это платформа разработки на основе EC, где все сущности являются экземплярами GameObject
, а функции, делающие их «видимыми», «перемещаемыми», «взаимодействующими» и т. д., предоставляются классами, расширяющими Component
.
Панель иерархии и панель инспектора редактора Unity предоставляют мощный способ сборки вашего приложения, присоединения компонентов, настройки их начального состояния и начальной загрузки вашей игры с гораздо меньшим количеством исходного кода, чем обычно.
Тем не менее, как мы уже говорили, мы можем столкнуться с проблемой «слишком много функций» и оказаться в гигантской иерархии с функциями, разбросанными повсюду, что значительно усложнит жизнь разработчика.
Вместо этого, думая в стиле MVC, мы можем начать с разделения вещей в соответствии с их функциями, структурируя наше приложение, как в примере ниже:
Адаптация MVC к среде разработки игр
Теперь я хотел бы представить две небольшие модификации общего шаблона MVC, которые помогают адаптировать его к уникальным ситуациям, с которыми я сталкивался при создании проектов Unity с помощью MVC:
- Ссылки на классы MVC легко разбросаны по всему коду. — В Unity разработчикам обычно приходится перетаскивать экземпляры, чтобы сделать их доступными, или же обращаться к ним с помощью громоздких операторов поиска, таких как
GetComponent( ... )
. - Ад потерянных ссылок наступит, если Unity выйдет из строя или какая-то ошибка приведет к исчезновению всех перетаскиваемых ссылок. - Это делает необходимым иметь один корневой объект ссылки, через который можно получить доступ ко всем экземплярам в приложении и восстановить их. - Некоторые элементы инкапсулируют общую функциональность, которая должна быть многократно использована и которая естественным образом не попадает ни в одну из трех основных категорий модели, представления или контроллера. Я предпочитаю называть их просто компонентами . Они также являются «компонентами» в смысле Entity-Component, но просто действуют как помощники в структуре MVC. - Например, компонент
Rotator
, который только вращает объекты с заданной угловой скоростью и ничего не уведомляет, не сохраняет и не решает.
Чтобы решить эти две проблемы, я придумал модифицированный шаблон, который я назвал AMVCC или Application-Model-View-Controller-Component.
- Приложение — единая точка входа в ваше приложение и контейнер всех важных экземпляров и данных, связанных с приложением.
- MVC — вы уже должны это знать. :)
- Компонент . Небольшой хорошо продуманный скрипт, который можно использовать повторно.
Эти две модификации удовлетворили мои потребности во всех проектах, в которых я их использовал.
Пример: 10 отскоков
В качестве простого примера давайте рассмотрим небольшую игру под названием 10 Bounces , в которой я буду использовать основные элементы шаблона AMVCC.
Настройка игры проста: Ball
со SphereCollider
и Rigidbody
(которое начнет падать после «Играть»), Cube
в качестве земли и 5 скриптов для создания AMVCC.
Иерархия
Перед написанием сценария я обычно начинаю с иерархии и создаю схему своего класса и активов. Всегда следуйте этому новому стилю AMVCC.
Как мы видим, view
GameObject содержит все визуальные элементы, а также элементы с другими сценариями View
. model
и controller
GameObject для небольших проектов обычно содержат только соответствующие сценарии. Для более крупных проектов они будут содержать GameObjects с более конкретными сценариями.
Когда кто-то, просматривающий ваш проект, хочет получить доступ:
- Данные: перейдите в
application > model > ...
- Логика/рабочий процесс: перейдите в
application > controller > ...
- Рендеринг/Интерфейс/Обнаружение: Перейдите в
application > view > ...
Если все команды будут следовать этим простым правилам, устаревшие проекты не станут проблемой.
Обратите внимание, что контейнера Component
нет, потому что, как мы уже говорили, они более гибкие и могут быть присоединены к различным элементам на досуге разработчика.
Сценарии
Примечание. Показанные ниже сценарии являются абстрактными версиями реальных реализаций. Подробная реализация не принесет большой пользы читателю. Однако, если вы хотите узнать больше, вот ссылка на мою личную структуру MVC для Unity, Unity MVC. Вы найдете основные классы, которые реализуют структурную структуру AMVCC, необходимую для большинства приложений.
Давайте посмотрим на структуру скриптов для 10 Bounces .
Прежде чем начать, для тех, кто не знаком с рабочим процессом Unity, давайте кратко поясним, как сценарии и игровые объекты работают вместе. В Unity «Компоненты» в смысле Entity-Component представлены классом MonoBehaviour
. Чтобы он существовал во время выполнения, разработчик должен либо перетащить его исходный файл в GameObject (который является «сущностью» шаблона Entity-Component), либо использовать команду AddComponent<YourMonobehaviour>()
. После этого скрипт будет создан и готов к использованию во время выполнения.
Для начала мы определяем класс Application («A» в AMVCC), который будет основным классом, содержащим ссылки на все экземпляры игровых элементов. Мы также создадим вспомогательный базовый класс под названием Element
, который даст нам доступ к экземпляру приложения и его дочерним экземплярам MVC.
Имея это в виду, давайте определим класс Application
(«A» в AMVCC), который будет иметь уникальный экземпляр. Внутри него три переменные, model
, view
и controller
, дадут нам точки доступа для всех экземпляров MVC во время выполнения. Эти переменные должны быть MonoBehaviour
с public
ссылками на нужные сценарии.
Затем мы также создадим вспомогательный базовый класс под названием Element
, который даст нам доступ к экземпляру приложения. Этот доступ позволит каждому классу MVC достигать всех остальных.

Обратите внимание, что оба класса расширяют MonoBehaviour
. Это «Компоненты», которые будут прикреплены к GameObject «Entities».
// 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
обычно действуют как контейнеры для более специализированных экземпляров, но поскольку это простой пример, только представление будет иметь вложенную структуру. Модель и Контроллер можно сделать в одном скрипте для каждого:
// 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!!”); } }
Когда все скрипты созданы, мы можем приступить к их подключению и настройке.
Структура иерархии должна быть такой:
- application [BounceApplication] - model [BounceModel] - controller [BounceController] - view [BounceView] - ... - ball [BallView] - ...
На BounceModel
мы можем увидеть, как это выглядит в редакторе Unity:
BounceModel
с полями bounces
и winCondition
.
Со всеми установленными скриптами и запущенной игрой мы должны получить этот вывод на панели консоли .
Уведомления
Как показано в приведенном выше примере, когда мяч падает на землю, его представление выполняет app.controller.OnBallGroundHit()
, который является методом. Ни в коем случае нельзя делать это для всех уведомлений в приложении. Однако, по моему опыту, я добился лучших результатов, используя простую систему уведомлений, реализованную в классе приложения AMVCC.
Чтобы реализовать это, давайте обновим макет 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 в реальном мире
В этом примере показан простой вариант использования шаблона AMVCC. Корректировка вашего мышления с точки зрения трех элементов MVC и обучение визуализации сущностей в виде упорядоченной иерархии — это навыки, которые следует совершенствовать.
В более крупных проектах разработчики столкнутся с более сложными сценариями и сомнениями относительно того, должно ли что-то быть представлением или контроллером, или следует более тщательно разделить данный класс на более мелкие.
Эмпирические правила (Эдуардо)
Нигде нет «Универсального руководства по сортировке MVC». Но есть несколько простых правил, которым я обычно следую, чтобы определить, следует ли определять что-то как Модель, Представление или Контроллер, а также когда разбивать данный класс на более мелкие части.
Обычно это происходит органично, пока я думаю об архитектуре программного обеспечения или во время написания сценария.
Сортировка классов
Модели
- Храните основные данные и состояние приложения, такие как
health
игрока илиammo
к оружию. - Сериализация, десериализация и/или преобразование между типами.
- Загружать/сохранять данные (локально или в Интернете).
- Уведомлять контролеров о ходе операций.
- Сохраните состояние игры для конечного автомата игры.
- Никогда не получайте доступ к представлениям.
Просмотры
- Может получать данные из моделей, чтобы представлять пользователю актуальное состояние игры. Например,
player.Run()
метода View может внутренне использоватьmodel.speed
для демонстрации способностей игрока. - Никогда не следует изменять модели.
- Строго реализует функциональные возможности своего класса. Например:
-
PlayerView
не должен реализовывать обнаружение ввода или изменять состояние игры. - Представление должно действовать как черный ящик, который имеет интерфейс и уведомляет о важных событиях.
- Не хранит основные данные (такие как скорость, здоровье, жизни и т. д.).
-
Контроллеры
- Не храните основные данные.
- Иногда может фильтровать уведомления от нежелательных представлений.
- Обновляйте и используйте данные модели.
- Управляет рабочим процессом сцены Unity.
Иерархия классов
В этом случае я выполняю не так много шагов. Обычно я понимаю, что какой-то класс необходимо разделить, когда переменные начинают показывать слишком много «префиксов» или начинают появляться слишком много вариантов одного и того же элемента (например, классы Player
в MMO или типы Gun
в FPS).
Например, одна Model
, содержащая данные игрока, будет иметь много playerDataA, playerDataB, playerDataA, playerDataB,...
или Controller
, обрабатывающий уведомления игрока, будет иметь OnPlayerDidA,OnPlayerDidB,...
. Мы хотим уменьшить размер скрипта и избавиться от префиксов player
и OnPlayer
.
Позвольте мне продемонстрировать использование класса Model
, потому что его проще понять, используя только данные.
Во время программирования я обычно начинаю с одного класса Model
, содержащего все данные для игры.
// Model.cs class Model { public float playerHealth; public int playerLives; public GameObject playerGunPrefabA; public int playerGunAmmoA; public GameObject playerGunPrefabB; public int playerGunAmmoB; // Ops Gun[CDE ...] 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
содержится в классе, позволяет создавать список Prefabs
(предварительно сконфигурированные игровые объекты для быстрого дублирования и повторного использования в игре) для каждой категории и сохранять их для последующего использования.
Напротив, если бы вся информация об оружии хранилась вместе в одном классе GunModel
в таких переменных, как gun0Ammo
, gun1Ammo
, gun0Clips
и т. д., то пользователь, столкнувшись с необходимостью Gun
данные об оружии, должен был бы хранить всю информацию целиком. Model
, включающая нежелательные данные Player
. В этом случае было бы очевидно, что новый класс GunModel
был бы лучше.
Как и во всем, есть две стороны медали. Иногда можно излишне разделить и увеличить сложность кода. Только опыт может отточить ваши навыки настолько, чтобы найти лучшую сортировку MVC для вашего проекта.
Заключение
Существует множество шаблонов программного обеспечения. В этом посте я попытался показать тот, который больше всего помог мне в прошлых проектах. Разработчики должны всегда впитывать новые знания, но также всегда подвергать их сомнению. Я надеюсь, что этот урок поможет вам узнать что-то новое и в то же время послужит отправной точкой для разработки вашего собственного стиля.
Кроме того, я настоятельно рекомендую вам изучить другие шаблоны и найти тот, который подходит вам лучше всего. Хорошей отправной точкой является эта статья в Википедии с прекрасным списком паттернов и их характеристик.
Если вам нравится шаблон AMVCC и вы хотите его протестировать, не забудьте попробовать мою библиотеку Unity MVC , которая содержит все основные классы, необходимые для запуска приложения AMVCC.
Дальнейшее чтение в блоге Toptal Engineering:
- Разработка ИИ в Unity: Учебное пособие по конечному автомату