Найти в Дзене
Помоги себе сам

Создание игры с самых основ (Unity). Практика

В предыдущей части статьи я рассказал про базовые теоретические аспекты работы с игровым движком Unity. В этой части статьи на примере реализации интерактивной модели мозга одноклеточного постараюсь на конкретном примере, пропуская сказанное в первой части статьи, максимально осветить всё с чем можно столкнуться при создании игры. Таким образом эти три статьи, как пазл, сложатся в цельную картину и надеюсь будут полезны и интересны читателю. Чаще игра состоит из нескольких сцен, чем из одной: В своём проекте я сделаю сцену с главным меню и собственно сцену с игровым уровнем. В дальнейшем легко можно добавить ещё сцены или Pop-up используя UI, созданный изначально для главного меню. Порядок сцен настраивается в File -> Build Settings. Можно добавлять как кнопкой Add Open Scenes, так и просто перетащив их мышкой из папки проекта. Первой запустится самая верхняя (та что с индексом 0), для изменения порядка перетащите сцену мышкой. Изначально в проекте есть сцена SampleScene. Переименую е
Оглавление

В предыдущей части статьи я рассказал про базовые теоретические аспекты работы с игровым движком Unity. В этой части статьи на примере реализации интерактивной модели мозга одноклеточного постараюсь на конкретном примере, пропуская сказанное в первой части статьи, максимально осветить всё с чем можно столкнуться при создании игры. Таким образом эти три статьи, как пазл, сложатся в цельную картину и надеюсь будут полезны и интересны читателю.

Сцены

Чаще игра состоит из нескольких сцен, чем из одной:

  • Главного меню, где игрок может выбрать новую игру или сохранение, поменять настройки языка и звука. Тут же можно добавить проверку наличия обновлений, предложения покупок или информацию по эвентам.
  • Каждый игровой уровень (если он не процедурно генерируемый) логичнее делать в отдельной сцене.
  • Дополнительные сцены, такие как страница с выбором сохранения или какие-то специфичные настройки. Если есть мультиплеер - дополнительными сценами могут быть лобби и таблица рейтингов.

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

Порядок сцен настраивается в File -> Build Settings. Можно добавлять как кнопкой Add Open Scenes, так и просто перетащив их мышкой из папки проекта. Первой запустится самая верхняя (та что с индексом 0), для изменения порядка перетащите сцену мышкой.

Главное меню

Изначально в проекте есть сцена SampleScene. Переименую её в более осмысленное название (ModelingLevel). Вообще это полезно - называть всё (классы, методы, переменные итд) осмысленно - тогда снижается потребность в комментариях и сразу становится понятно что за что отвечает.

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

1. В окне проекта в уже существующей папке Scenes создал новую сцену и дал ей понятное название (MainMenu). После двойного клика она открылась для редактирования.

2. Создал префаб в котором будет UI главного меню. Я уже рассказывал про стратегии хранения файлов в проекте в предыдущей части этой этого цикла статей, поэтому просто напишу как в этом примере сделаю. Создам папку Prefabs, в ней папку с понятным названием (UI Elements).

2.1. Добавлю на сцену UI -> Canvas и дам ему понятное название (MainMenuUI). В этом случае лучше добавить по правому клику мышки в иерархи, тогда все обязательные поля будут заполнены значениями по-умолчанию и подключены дополнительные компоненты, что удобно.

2.2. Правой кнопкой мышки (в иерархии) на Canvas добавлю UI -> Panel. При работе с UI удобно переключить вид сцены в 2D-режим.

Выделим панель кликом (можно и на сцене, но лучше в иерархии) и посмотрим на её параметры в инспекторе. Когда её добавили таким способом, то как и в случае с Canvas были сразу заполнены значения и дополнительные компоненты.

Всё интуитивно понятно, при необходимости можно поглядеть в документации по интересующему компоненту. Из не очевидного: клик на пиктограмме stretch открывает выбор режимов масштабирования, а кнопка Shift в этом меню покажет варианты со смещённым Pivot'ом.

Pivot - это координаты точки от которой будут считаться смещения (0.5, 0.5 - это середина родительского элемента). Anchors указывает допустимый диапазон масштабирования. Вместе эти два параметра (и конечно жёсткие ограничения размеров и отступов в пикселях) позволяют сделать такой интерфейс, который не будет "уезжать" за края экрана или наоборот наслаиваться при работе на разных устройствах. Лучше всего поэкспериментировать, создав панель и запустив игру: сверху во вкладке Game можно переключать разные Aspect'ы экрана или просто "посжимать" эту вкладку.

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

Добавлю в папку проекта картинку, которая будет рамкой (и фоном - в моём примере фон есть, но он прозрачный). И изменю (в инспекторе) тип картинки на Sprite.

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

После изменения типа на Sprite чуть ниже (в инспекторе) появится кнопка Sprite Editor. При первом клике на неё попросит установить пакет для работы со спрайтами. В меню Window -> Package Manager найду поиском пакет 2D Sprite (если ничего нет - переключите область поиска на Packages: Unity Registry) и включу его.

После этого снова в картинке нажму на Sprite Editor и в нём разделю изображение на девять частей: углы, границы и центр. Для этого просто передвину зелёные линии и нажму Apply.

Выделю Panel и перетащу в компонент Image спрайт с границами\фоном.

2.3. Добавлю кнопки (с такими же рамками) UI -> Button TMP для старта и выхода.

Text Mesh Pro (TMP) элементы были добавлены в Unity не так давно (лет 5 назад) и они позволяют использовать расширенный функционал вывода текстовой информации (в случае кнопки текста на ней), например оформления в различных стилях или добавления интерактивности (как на интернет-сайтах). При первом добавлении элемента TMP - Unity попросит добавить его ассеты - соглашаюсь.

После добавления первой кнопки настрою её, а затем нажму на ней правой кнопкой мыши в инспекторе -> Duplicate. Изменю положение, текст и в дальнейшем обработчик.

После настройки интерфейса перетащу в папку UI Elements всю иерархию (схватив за Canvas) и таким образом создам префаб.

Отделить UI в отдельный префаб полезно как с позиции переиспользования (например открыть главное меню во время игры по кнопке Esc), так и с точки зрения удобства изменения через некоторое время.

3. В папке UI Elements создам префаб SceneTransition и одноимённый скрипт SceneTransition.cs, который сразу к нему добавлю.

Открою скрипт SceneTransition.cs двойным кликом мышки (у меня откроется Visual Studio) и в нём создам два обработчика кликов. Я люблю называть их сложно, в стиле стандартных, чтобы потом легко было найти. Не используемые методы Start и Update я удалю и вместо них добавлю:

public void OnButtonToModelingClick() {
SceneManager.LoadScene("
ModelingLevel"); }
public void OnButtonExitFromAppClick() { Application.Quit(); }

В SceneManager.LoadScene лучше указывать название, а не номер сцены для улучшения понятности кода и ликвидации необходимости править код при изменении порядка сцен.

3.1. Перетащу его на сцену в точку с координатами (0, 0, 0).

Можно было бы закрепить скрипт на камере и использовать её, но с ростом проекта настанет момент когда вы заметите, что к моменту запуска сцены ещё не все ресурсы загружены. Тогда захочется добавить затенение, индикатор загрузки сцены итд. И в случае нескольких сцен иметь один префаб, в котором изменить функционал на предоставляемый методом SceneManager.LoadSceneAsync будет удобно.

Вы можете менять префаб прямо на сцене, не заходя в него, что удобно в случае работы с UI. Например если вы какую-то модель внутри префаба передвинули - это значит вы меняли параметры компонента Transfom этой модели. Тогда чтобы применить это изменение во всех местах, где используется этот префаб - надо нажать три точечки в правом верхнем углу изменённого компонента и там выбрать Modified Component -> Apply to prefab. У компонентов которые не менялись относительно префаба этой строки не будет. Также отличия от оригинального префаба в дочерних элементах можно посмотреть в самом префабе, выделив его в иерархии. Там же будет кнопка для применения всех изменений сделанных с префабом.

3.2. Выделю кнопку и добавлю плюсиком новую строку в таблице обработчика OnClick. Затем перетащу SceneTransition (так как к нему добавлен интересующий нас скрипт) в левый столбец, тогда разблокируется правый и в нём выберу метод, выполняемый при клике на соответствующей кнопке.

Перетаскивание объекта на сериализуемое поле фактически создаёт зависимость, что требует индивидуального подхода к решению задачи или наоборот избыточных решений вроде использования Zenject. На этом этапе освоения Unity предлагаю ограничить себя двумя вариантами: Что надо будет выйти из префаба, развернуть его в иерархии и перетаскивать на нужные поля объекты со сцены. Либо программно задавать обработчики, и при необходимости искать объекты на сцене.

Перетащим в OnClick объект со скриптом и выберим метод, выполняемый при клике
Перетащим в OnClick объект со скриптом и выберим метод, выполняемый при клике

Обратите внимание, что при обработке событий внутри префаба автоматически не создастся на сцене элемент EventSystem, который координирует настроенные подобным образом события. Поищите его в иерархии и если не найдёте - то добавьте UI -> EventSystem.

Пара слов о шаблонах проектирования

В главном меню могут быть и пункты, не связанные с переключением между сценами (например кнопка выключить звук или вызвать Pop-up для выбора профиля) и которые больше нигде не будут использованы. Для них предлагаю использовать отдельный префаб, который будет являться менеджером. И соответственно использовать такой же подход для каждой группы задач, например работа со звуком, работа с UI, работа с управлением, командование врагами итд.

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

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

Окружение

Далее в статье будет идти речь про сцену, в которой будет происходить непосредственно геймплей (в моём примере - моделирование).

Создам в программе Blender 3D модели, которые планирую расположить в качестве игрового окружения на уровне. Если интересно научиться работе работе с 3D в Blender'e с самых основ, то я про это рассказывал тут. Экспортирую их в формат FBX и затем перетащу в проект в папку Environment, предварительно создав ее.

В случае окружения при импорте модели всё ненужное (скелет, анимацию итд) можно отключить. Если в дальнейшем возможна замена модели или переиспользование, то следует сделать префаб, приведя масштаб самой модели к (1, 1, 1) - это сэкономит время на подгонке новой модели к существующему окружению.

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

У меня в проекте моделируется поведение микроорганизмов в Чашке Петри. Поэтому для окружения мне понадобится материал воды.

Создам новый материал кликнув правой кнопкой мышки в папке проекта Create -> Material и переименую его в WaterMat.

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

Изменю Rendering Mode на Transparent, а Source (в блоке Metalic) на Albedo Alpha.

Перетащу материал на модель на сцене (или зайду в префаб и перетащу там на модель) - он тут же поменяется со стандартного матового белого на материал воды.

Пара слов о шейдерах

В рамках этой статьи вопрос создания и настройки шейдеров глубоко затрагивать не буду, а обойдусь настройкой материала со стандартным шейдером. Но я хотел бы чуть подробнее рассказать про два шейдера, которые наверняка пригодятся в момент прототипирования или при создании простой 3D игры.

Для наложения текстуры подойдёт шейдер Standard (он изначально присвоен новому материалу), в котором следует перетащить картинку с текстурой в маленький квадратик слева от параметра Albedo. В отличии от шейдера Unlit -> Texture материал с ним принимает и отбрасывает тени, но если этого не надо, то конечно лучше использовать тот что проще.

И ещё полезны шейдеры Particles, по аналогии с текстурами Standard - тяжёлый, Unlit - простой. В него можно загружать как одиночную картинку, например перо на прозрачном фоне, так и спрайтшит (картинка с равномерно расставленными на ней спрайтами), например с кадрами анимации лопающегося пузыря. Как понятно из названия шейдера - он оптимизирован для использования в системе частиц, которая представляет собой генератор объектов, вылетающих по настроенным алгоритмам из источника. В сети есть неплохие мануалы по системе частиц, но обычно они затрагивают самые основы не доходя до многого простого, но интересного - поэтому я хотел бы написать свою заметку по этому вопросу (и если дойдут до неё руки - тут появится ссылка).

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

Настройка камеры

Так как у меня сцена с фиксированной камерой, то я сразу после наброска окружения настрою камеру. Для этого выберу во вкладке Scene интересный мне ракурс, выделю камеру и нажму в меню Game Object -> Align With View. Если в вашей игре камера может перемещаться, например быть дочерней для объекта персонажа, тогда её настройкой можно заняться и позже.

Интерфейс

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

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

Для UI в игре расположу на Canvas'е (переименую его в UIInRuntime) текстовое поле и кнопку. Так как планируется к ним обращаться - дам им понятные названия: UIInRuntimeText и MainMenuButton. В текстовое поле я буду выводить информацию в процессе игры. У вас это может быть несколько полей, например: текущий счёт, здоровье, или количество патронов - принцип будет тем же. А кнопка как можно понять из её названия будет открывать главное меню (в этой же сцене в PopUp'e).

После настройки текстового поля и кнопки создам префаб, перетащив UI из иерархии (за Canvas) в папку UI Elements. Создам и прикреплю к этому префабу одноимённый скрипт - он будет отвечать за отображение интерфейса в этой сцене.

Перетащу на сцену префабы SceneTransition и MainMenuUI. Настрою расположение, поменяю надпись на кнопке запуска в кнопку перезапуска, снова назначу кнопкам обработчики. Благодаря тому, что всё повторяющееся в различных сценах было сохранено в префабах, настройка кнопки вызова главного меню была существенно облегчена.

Открою скрипт UIInRuntime.cs в редакторе. И сразу добавим туда код, чтобы при запуске скрывался Pop-up с главным меню.

[SerializeField] private GameObject _mainMenuUI;
private void DisableMainMenu() { _mainMenuUI.SetActive(false); }
private void Awake() {
if (_mainMenuUI != null) {
DisableMainMenu(); }
else {
Debug.LogWarning("MainMenuUI не назначен в инспекторе"); } }

Может возникнуть желание использовать однострочные конструкции вроде такой:

(_mainMenuUI != null ? (System.Action)DisableMainMenu : () => Debug.LogWarning("MainMenuUI не назначен в инспекторе"))();

Да, она будет работать, но так лучше не делать - ведь снижение читаемости не оправдывает экономии места. Также стоит помнить, что в Unity использование в подобных случаях конструкции is not null будет ошибкой.

Допишем оставшуюся логику и теперь можно не бояться забыть перетащить на сериализуемое поле MainMenuUI (из иерархии).

private void Update() { if (Input.GetKeyDown(KeyCode.Escape)) { CallMainMenu(); } }
public void CallMainMenu() {
if (_mainMenuUI != null) {
if (_mainMenuUI.activeSelf) {
DisableMainMenu(); }
else {
EnableMainMenu(); } }
else { Debug.LogWarning("MainMenuUI не назначен в инспекторе"); } }
private void EnableMainMenu() { _mainMenuUI.SetActive(true); }
private void DisableMainMenu() { _mainMenuUI.SetActive(false); }

Теперь можно настроить обработчик для нажатия на кнопке открытия главного меню. Также функционал этой кнопки продублировал на кнопку клавиатуры Esc.

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

Теоретически можно было бы и не проверять не забыли ли мы назначить в инспекторе объект (ведь мы же себе доверяем), но лучше не лениться.

Геймплей

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

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

Создание объектов

Создам объект микроба. Для этого создам пустой префаб Mikrob (в созданной для него одноимённой папке в Prefabs). В него добавлю картинку, которая будет плоско плавать на поверхности моей чаши петри. Пусть вас не запутывает то, что я буду использовать UI -> Image (он создаст под себя Canvas, где надо поменять Render Mode на World Space). Импортированное изображение я положу в ту же папку и перетащу её на Image.

Всю настройку внешнего вида, размеров и смещения Image я делаю внутри префаба таким образом, чтобы сам префаб имел размеры (1, 1, 1), смещения и повороты (0, 0, 0). Тогда перенеся такой префаб на сцену в любые координаты со смещением в горизонтальной плоскости я буду иметь вид как задумано, а какие-то косметические правки я потом буду делать исключительно внутри префаба.

Теперь пришло время создать скрипт GameManager.cs и закрепить его на камере. Отредактирую его, добавив логику создания 1000 объектов в пределах моей чаши петри:

for (int i = 0; i < _mikrobsCount; i++){
Vector2 randomPoint = Random.insideUnitCircle * _petrisDishRadius;
Vector3 position = new Vector3(randomPoint.x, 0f, randomPoint.y);
Quaternion rotation = Quaternion.Euler(0f, Random.Range(0f, 360f), 0f);
GameObject microb = Instantiate(_mikrob, position, rotation); }
Скриншот того что у меня на этом этапе. Кроме результата, тут видна моя логика организации иерархии сцены и проекта. Раскрыл главное меню чтобы ещё раз показать рамки.
Скриншот того что у меня на этом этапе. Кроме результата, тут видна моя логика организации иерархии сцены и проекта. Раскрыл главное меню чтобы ещё раз показать рамки.

Дам пару комментариев по коду:

Может возникнуть желание использовать возможности класса System.Random, например если вас так учили на уроках по программированию или по совету нейросети. На практике не зря подобные функции были переопределены в классе UnityEngine - при отсутствии необходимости в отказе от использования функционала, предоставляемого классом UnityEngine - выбирайте именно его. Например если переделать этот пример на использование System.Random, то придётся напрягать мозг по вопросам синусов\косинусов, плодить временные переменные итд.

Число микробов в 1000 является представителем антипатерна магическое число, так как не понятно почему оно не 100 или 100500 и будет требовать правок кода для подбора оптимального значения. Поэтому его стоит указывать так: [SerializeField] private int _mikrobsCount = 1000; - тогда в любой момент в инспекторе можно изменить это число в один клик. Аналогично и с радиусом чаши и любыми другими параметрами. С самим префабом микроба тоже можно так поступить, но я использую конструкцию [SerializeField] private GameObject _mikrob; , а затем перетаскиваю объект в инспекторе. При этом в коде каждый раз проверяю на null.

В этот момент может возникнуть вопрос почему бы не заменить [SerializeField] private на public, ведь MonoBehaviour автоматически сериализует публичные поля? Да, так можно делать, но это считается плохим стилем программирования. Если строго придерживаться стиля оформления кода подобному тому, что у меня в примере - по самому объявлению поля становится понятно как будет задано его значение (из инспектора или из кода) и как будет использовано (в самом классе или из другого класса).

На скриншоте видно, что было бы красиво, чтобы микробы отбрасывали тени на дно чаши, чего они не делают будучи элементами UI. Для этого надо переделать их в 3D Object -> Plane, и создать им материал с текстурой, использующий шейдер, который умеет отбрасывать тени. Но как это скажется на производительности? Моя модель делается с акцентом на другое, поэтому эту задачу я оставлю на этап "полировки".

Пара слов об отладке

Наверняка вы обратили внимание на то, что для отладки чаще всего я использую Debug.Log - пример уже приводил в заметке с размышлениями про скриптинг в Unity.

Для визуализации направлений предпочитаю использовать DrawLine и DrawRay (как методы расширения класса Gizmos, а не как методы класса Debug).

Ещё бывает удобно сделать сериализованное поле, в которое я в Update вывожу текущее значение сразу нескольких параметров (в режиме Unity Editor - в блоках #if UNITY_EDITOR / #endif). Визуализацию областей я делаю не только с помощью методов класса Gizmos, но также и с помощью активации объектов (тоже в режиме Unity Editor) - тогда они будут видны и во вкладке Game.

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

Собственная логика объектов

Так как у меня класс микроба моделирует некоторую структуру, состоящую из взаимодействующих объектов других классов (нейронов), поэтому их я опишу в отдельном классе (и соответственно создам одноимённый файл .cs) Neuron, который не буду наследовать от MonoBehaviour. Так делать не обязательно, но теоретически это позитивно скажется на производительности, так как в 1000 микробов будет как минимум 7 нейронов и все они пусть в холостую, но проходят цикл MonoBehaviour, о котором я рассказывал в предыдущей части статьи, а также резервируют под себя чуть-чуть памяти. Так что хорошим стилем считается делать обычный C# класс, если не требуется иного.

Добавлю в префаб микроба одноимённый скрипт. В нём опишу метод задания начальных параметров весов нейронных связей. Первый раз задам их извне (из GameManager) и отправлю в плаванье.

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

Вывод на UI текущих значений

В моей модели в жизни микроба могут случиться два события: достижение цели и гибель от голода.

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

В обоих случаях статистика модели изменяется. Можно придумать несколько способов как такое реализовать:

Первое что приходит в голову - менять какой-нибудь счётчик, например в PlayerPrefs, и опрашивать его из Update. Такой подход вызовет головную боль, когда надо будет реализовывать реакцию нескольких приёмников на несколько источников событий.

Можно добавить в префаб микроба ссылку на поле счётчика и менять его напрямую. Для этого счётчик назначается в инспекторе на сериализуемое поле объекта, который будет создавать объекты (GameManager), а затем назначать её в публичное поле экземпляра микроба. Это создаст зависимости, но при изменении UI надо будет изменять только в инспекторе не меняя код, а добавление новых приёмников и источников никак не будет влиять на старые.

Но на мой взгляд для этой задачи лучше подойдёт следующий способ, связанный с вызовом событий (вариация на тему паттерна Наблюдатель).

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

public static MikrobsManager Instance;
public int Score { get; private set; }
public event Action<int> OnScoreChanged;
private void Awake() {
if (Instance == null) {
Instance = this; }
else {
Destroy(gameObject); } }
public void AddScore(int score) {
Score += score;
OnScoreChanged?.Invoke(Score); } }

Микроб передаёт ему значение так:

MikrobsManager.Instance.AddScore(scoreValue);

А UI подписывается в Awake и затем значение само будет автоматически обновляться:

MikrobsManager.Instance.OnScoreChanged += UpdateScoreDisplay;
. . .
private void UpdateScoreDisplay(int score) { scoreText.text = "Score: " + score; }

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

Координация меду объектами

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

Например я хочу чтобы при нажатии на кнопку пробела все мои микробы меняли цвет на исходный. Проверять в каждом микробе в Update даже звучит плохо: это куча ненужных проверок каждый кадр и размазывание обработки управления по разным фрагментам кода, что может привести к проблеме с повторным использованием той же кнопки.

У нас уже есть контроллер в котором обрабатывается кнопка Esc для вызова главного меню, поэтому логичнее всего все другие кнопки клавиатуры, обрабатывать там же. Следуя той же логике (что всё похожее обрабатывается в одном и том же месте), в MikrobsManager добавим public event Action OnKeySpacePressed, а каждый микроб при создании подписывается на него и реагирует.

В случаях когда мир должен реагировать на изменение параметров одного объекта (например главного героя шутера или RPG), контроллером, отвечающим за них логичнее всего сделать самого этого персонажа. Тогда такая система построенная на делегатах Action<T>, будет реализовывать паттерн Наблюдатель чуть ли не в самом классическом его виде, а значит не только упростит масштабирование, но и позволит избежать многих случайных ошибок (например когда тот кто возможно должен был реагировать на событие уже не существует, а мы пытаемся заставить его это сделать).

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

При подобной генерации объектов (как в моей модели) искать объект на сцене составляет некоторую проблему, но опять же приходит в голову несколько вариантов, но после уже сделанного - самым очевидным является использование уже существующего менеджера, обрабатывающего события, происходящие с микробами. Для этого в нём надо вести некий список всех микробов (используя в качестве идентификатора например полученный с помощью gameObject.GetInstanceID() ), упорядоченный по дате создания. А затем выбирать объект для уничтожения с помощью Linq.

Пара слов об оптимизации программного кода

Мне сразу показалось неоптимальным каждый раз переупорядочивать список (OrderByDescending либо использовать Aggregate для поиска максимального без упорядочивания), так как события деления становятся всё чаще пока популяция полностью не заменится на обученную и тогда продолжают происходить стабильно часто. Поэтому я решил попробовать оптимизировать этот момент.

Читатель, который зашёл сюда с опытом разработки на C# знает, что Unity отстаёт от поддержки современных версий языка, а до C# 8.0 (его начала поддерживать Unity 2022 и свежее) Linq работал не очень эффективно и на списках в 1000 элементов зачастую использовали самописные методы. Так что имейте это в виду, если ещё используете например Unity 2019.

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

MicrobsList.OrderByDescending(x => x.Value.GetComponent<Mikrob>().LifeTime).FirstOrDefault()

Затем создал ещё один словарь (в качестве основного списка микробов я также использую словарь с ключом InstanceID, но значением - ссылкой на gameObject) для кэширования значений LifeTime, который в корутине с определённой периодичностью обновлял упорядоченный список.

Для оценки производительности использовал Profiler. Сравнение не показало ощутимых отличий. Но стабильный небольшой профит по производительности всё же был у более простого (и на первый взгляд менее оптимального метода) - я даже пару раз повторил тест для того чтобы убедиться, что точно в этом было дело.

Так что здесь напишу совет не пытаться излишне оптимизировать производительность в ущерб простоте, если выгода не доказана - возможно даже на 1000 объектах (как у меня в проекте) это не даст нужного эффекта.

Взаимодействие игрока с объектами

В моём случае взаимодействие игрока с объектом будет заключаться в выделении игроком конкретного объекта (одноклеточного), после чего объект будет подсвечен, а на UI будет отображены его характеристики.

Логичнее всего обрабатывать клик на объекте в том же объекте по которому кликнули, инициировать событие в контроллере, передавая вместе с ним всю необходимую информацию (в моём примере достаточно своего InstanceID), а все заинтересованные (UI или например если захочу сделать некого охотника за выбранным микробом) подпишутся на это событие и отреагируют. Но для того, чтобы понять хорошая ли это идея надо вспомнить как обрабатывается клик.

Клик мышкой (или пальцем) на конкретной точке может быть не только на конкретном объекте (микробе), но и в момент наложения нескольких игровых объектов или по объектам заслонённым интерфейсом. В этот момент из камеры создаётся луч, который проходит сквозь точку на котоврой вы кликнули и улетает в бесконечность. Пока не столкнётся с объектом на котором есть коллайдер и выполнит на нём метод OnMouseDown(). А следователно ранее применяемый в этом проекте подход с менеджерами, основанный на событиях покажет себя на первый взгляд хорошо.

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

[RequireComponent(typeof(BoxCollider))]
public class Mikrob : MonoBehaviour {
private BoxCollider bc;
[SerializeField] private Vector3 _microbsBoxSize = new(2.5f, 5f, 5f);
...
private void Awake() {
bc = GetComponent<BoxCollider>();
bc.enabled = true;
bc.isTrigger = true;
bc.size = _microbsBoxSize;

С увеличением сложности проекта такой подход начинает сильно ограничивать разработку: например однажды захочется, чтобы обрабатывались все объекты в точке клика, но при клике на UI не срабатывал (а например при клике на другой объект или UI - срабатывал).

В этом случае вместо вызова OnMouseDown в объекте, надо будет в менеджере UI (в моём проекте это UIInRuntime) в Update обрабатывать все объекты, на которых есть коллайдер, через которые прошёл луч (Ray) в момент клика.

Это можно сделать несколькими способами, но для проекта подобного моему (а также стратегии, изометрические RPG и друих с наслоением объектов и чётким отделением UI) я бы рекомендовал использовать результаты Physics.RaycastAll, обработанные с помощью LINQ. Это предоставит возможность использовать стандартное исключение слоёв из результатов рэйкаста (настраивается в параметрах проекта), а также индивидуальный подход к обработке префабов, например с помощью пустых скриптов, котоыре потом проверяются с помощью TryGetComponent<пустой_скрипт_присоединённый_к_объекту>.

Проверка условия победы или поражения

Если победу фиксирует триггер (преодоление финишной черты, попадание снаряда в цель итд), а попыток бесконечно много, то тут и говорить не о чем.

Но если условие победы или поражения определяется каким-то параметром (здоровье упало ниже нуля или счёт достиг определённого значения), то теперь прочитав эту статью почти до конца вы понимаете, что проверять условие победы в Update - худший вариант.

Значительно лучше подписаться на какое-нибудь существующее событие, например в моей модели - OnScoreChanged и проверять его в этот момент.

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

Если бы у меня существовало условие поражения, например все микробы умерли от голода, то также можно было бы подписаться на событие от каждого одноклеточного, либо проверять когда средний LifeTime станет равным нулю.

А если победа считается по факту событий инициируемых физикой (например крепость врага разрушена полностью), то подойдет проверка в FixedUpdate.

Вместо вывода

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

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