Я опубликовал первую статью «50 советов по работе с Unity» 4 года назад. Несмотря на то, что бoльшая её часть всё ещё актуальна, многое изменилось по следующим причинам:

  • Unity стал лучше. Например, теперь я могу доверять счётчику FPS. Возможность использования Property Drawers снизила необходимость написания пользовательских редакторов (Custom Editors). Способ работы с префабами стал меньше требовать заданных встроенных префабов (nested prefabs) и их альтернатив. Скриптуемые объекты стали более дружелюбными.

  • Улучшилась интеграция с Visual Studio, отладка стала намного проще и уменьшилась потребность в «обезьяньем» дебаггинге.

  • Стали лучше сторонние инструменты и библиотеки. В Asset Store появилось очень много ассетов, упрощающих такие аспекты, как визуальная отладка и логирование. Большая часть кода нашего собственного (бесплатного) плагина Extensions описана в моей первой статье (и многое из него описано здесь).

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

  • Я стал более опытным. За последние 4 года я поработал над многими проектами в Unity, в том числе над кучей прототипов игр, завершёнными играми, такими как Father.IO, и над нашим основным ассетом Unity Grids.

Эта статья является версией первоначальной статьи, переработанной с учётом всего вышеперечисленного.

Прежде чем перейти к советам, сначала я оставлю небольшое примечание (такое же, как и в первой статье). Эти советы подходят не ко всем проектам Unity:

  • Они основаны на моём опыте работы над проектами в составе небольших команд (от 3 до 20 человек).

  • У структурированности, возможности повторного использования, ясности кода и других аспектов есть своя цена: от размера команды, объёма проекта и целей проекта зависит то, стоит ли платить эту цену. Например, для геймджема вы всё это использовать не будете.

  • Использование многих советов — вопрос вкуса (возможно, есть отличающиеся, но всё равно хорошие техники для любого из перечисленных здесь советов).

На сайте Unity также есть рекомендации по работе над проектами (однако большинство из них направлены на повышение производительности проектов) (все они на английском):


Рабочий процесс


1. С самого начала определитесь с масштабом и создавайте всё одного масштаба. Если вы этого не сделаете, возможно, позже вам придётся переделывать ассеты (например, анимация не всегда правильно масштабируется). Для 3D-игр наверно лучше всего принять 1 единицу Unity равной 1 метру. Для 2D-игр, не использующих освещение и физику, обычно подходит 1 единица Unity, равная 1 пикселю (в «рабочем» разрешении). Для UI (и 2D-игр) выберите рабочее разрешение (мы используем HD или 2xHD) и создавайте все ассеты под масштаб в этом разрешении.

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

3. Применяйте контроль исходного кода и научитесь использовать его эффективно.

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

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

  • Используйте теги в качестве закладок.

  • Выберите стратегию ветвления и придерживайтесь её. Поскольку соединение сцен и префабов невозможно сделать плавным, организация ветвления может стать довольно сложной. Какой бы способ ветвления вы ни выбрали, он должен работать с вашей стратегией обмена сценами и префабами.

  • Используйте подмодули с осторожностью. Подмодули могут стать отличным способом поддержки повторно используемого кода, однако существует несколько опасностей:

    • Метафайлы для разных проектов в общем случае неодинаковы. Обычно это не является проблемой для кода, не использующего MonoBehaviour или скриптуемые объекты, однако для MonoBehaviour и скриптуемых объектов использование подмодулей может привести к утере кода.

    • Если вы работаете над несколькими проектами (один или несколько из которых используют подмодули), то иногда вы можете столкнуться с «лавиной обновлений», когда необходимо выполнить несколько итераций pull-merge-commit-push для разных проектов, чтобы стабилизировать код во всех проектах (а если во время этого процесса кто-то ещё вносит изменения, лавина может стать непрерывной). Одним из способов минимизации этого эффекта является внесение изменений в подмодули из проектов, которые к ним относятся. При этом проекты, использующие подмодули, должны будут всегда выполнять pull, и им никогда не придётся делать push.

4. Всегда отделяйте тестовые сцены от кода. Выполняйте коммиты временных ассетов и скриптов в репозиторий и удаляйте их из проекта, когда закончите работу с ними.

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

6. Импортируйте ассеты сторонних разработчиков в чистый проект и импортируйте новый пакет для своего использования уже оттуда. При непосредственном импорте в проект ассеты иногда могут приводить к проблемам:

  • Возможно возникновение коллизий (файлов или имён), особенно для ассетов, содержащих файлы в корне папки Plugins, или для тех, которые используют в своих примерах ассеты из Standard Assets.

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

Чтобы ассеты были в большей безопасности, пользуйтесь следующими инструкциями:

1. Создайте новый проект и импортируйте ассет.
2. Запустите примеры и убедитесь, что они работают.
3. Упорядочьте ассет в более подходящую структуру папок. (Обычно я не подгоняю ассет под свою собственную структуру папок. Но я проверяю, что все файлы находятся в одной папке и что в важных местах нет файлов, которые могут перезаписать уже имеющиеся файлы моего проекта.)
4. Запустите примеры и убедитесь, что они всё ещё работают. (Иногда случалось, что ассет «ломался», когда я перемещал его составляющие, но обычно такой проблемы не возникает.)
5. Теперь удалите составляющие, которые вам не нужны (такие как примеры).
6. Убедитесь, что ассет по-прежнему компилируется и префабы всё ещё имеют все свои связи. Если осталось ещё что-то незапущенное, протестируйте его.
7. Теперь выберите все ассеты и экспортируйте пакет.
8. Импортируйте его в свой проект.

7. Автоматизируйте процесс сборки. Это полезно даже в небольших проектах, но в особенности это полезно, когда:

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

Информацию о том, как это сделать, читайте в Unity Builds Scripting: Basic and advanced possibilities.

8. Документируйте свои настройки. Бoльшая часть документации должна находиться в коде, но кое-что необходимо задокументировать за его пределами. Заставлять разработчиков рыться в коде в поисках настроек значит тратить их время. Документированные настройки повышают эффективность (если поддерживается актуальность документов). Документируйте следующее:

  • Использование тегов.
  • Использование слоёв (для коллизий, culling и raycasting — указывайте, что в каком слое должно быть).
  • Глубина GUI для слоёв (что над чем должно располагаться).
  • Настройки сцены.
  • Структура сложных префабов.
  • Выбранные идиомы.
  • Настройка сборки.

Общие советы по коду


9. Размещайте весь свой код в пространстве имён. Это позволяет избежать конфликта кода ваших собственных библиотек и стороннего кода. Но не полагайтесь на пространства имён, когда стремитесь избежать конфликтов кода с важными классами. Даже если вы используете другие пространства имён, не берите в качестве имён классов «Object», «Action» или «Event».

10. Используйте утверждения (assertions). Утверждения полезны для тестирования инвариантов в коде и помогают избавиться от логических багов. Утверждения доступны через класс Unity.Assertions.Assert. Они проверяют условие и записывают в консоль сообщение, если оно неверно. Если вы не знаете, для чего могут быть полезны утверждения см. The Benefits of programming with assertions (a.k.a. assert statements).

11. Не используйте строки ни для чего, кроме отображения текста. В частности, не используйте строки для идентификации объектов или префабов. Существуют исключения (в Unity всё ещё есть некоторые элементы, к которым можно получить доступ только через имя). В таких случаях определяйте такие строки как константы в файлах, таких как AnimationNames или AudioModuleNames. Если такие классы становятся неуправляемыми, применяйте вложенные классы, чтобы ввести что-то вроде AnimationNames.Player.Run.

12. Не используйте Invoke и SendMessage. Эти методы MonoBehaviour вызывают другие методы по имени. Методы, вызываемые по имени, тяжело отследить в коде (вы не сможете найти «Usages», а SendMessage имеет широкую область видимости, которую отследить ещё сложнее).

Можно легко написать собственную версию Invoke c помощью Coroutine и actions C#:

public static Coroutine Invoke(this MonoBehaviour monoBehaviour, Action action, float time)
{
return monoBehaviour.StartCoroutine(InvokeImpl(action, time));
}

private static IEnumerator InvokeImpl(Action action, float time)
{
yield return new WaitForSeconds(time);

action();
}

Затем вы можете использовать её в MonoBehaviour таким образом:

this.Invoke(ShootEnemy); //где ShootEnemy - это невозвращающий значения (void) метод без параметров.

(Дополнение: кто-то предложил использовать в качестве альтернативы класс ExecuteEvent, часть системы событий Unity. Пока я знаю о нём не так много, но похоже, что его стоит изучить подробнее.)

13. Не позволяйте спауненным (spawned) объектам запутывать иерархию при выполнении игры. Установите в качестве родителя для них объект в сцене, чтобы при выполнении игры было проще находить объекты. Можно использовать пустой (empty) игровой объект или даже синглтон (см. ниже в этой статье) без поведения (behaviour), чтобы проще было получать к нему доступ в коде. Назовите этот объект DynamicObjects.

14. Будьте точны при использовании null в качестве допустимых значений, и избегайте их там, где это возможно.

Значения null полезны при поиске некорректного кода. Однако если вы приобретёте привычку игнорировать null, некорректный код будет успешно выполняться и вы ещё долго не заметите ошибок. Более того, она может объявиться глубоко внутри кода, поскольку каждый слой игнорирует переменные null. Я стараюсь вообще не использовать null как допустимое значение.

Я предпочитаю следующую идиому: не выполнять проверку на null и позволить коду вывалиться при возникновении проблемы. Иногда в повторно используемых методах я проверяю переменную на null и выдаю исключение вместо того, чтобы передавать её другим методам, в которых она может привести к ошибке.

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

Обычный сценарий часто используется для значений, настраиваемых в инспекторе. Пользователь может указать значение, но если он этого не сделает, будет использоваться значение по умолчанию. Лучший способ сделать это — использовать класс Optional‹T›, который оборачивает значения T. (Это немного похоже на Nullable‹T›.) Можно использовать специальный рендерер свойств для рендеринга поля с флажком и показывать поле значения только когда флажок установлен. (К сожалению, невозможно использовать непосредственно generic-класс, необходимо расширить классы для определённых значений T.)

[Serializable]
public class Optional
{
   public bool useCustomValue;
   public T value;
}

В своём коде вы можете использовать его таким образом:

health = healthMax.useCustomValue ? healthMax.Value : DefaultHealthMax;

Дополнение: многие люди подсказывают мне, что лучше использовать struct (не создаёт мусора и не может быть null). Однако это означает, что вы не сможете использовать его в качестве базового класса для non-generic-классов так, чтобы применять его для полей, которые можно использовать в инспекторе.

15. Если вы используете корутины (Coroutines), научитесь использовать их эффективно. Корутины могут быть удобным способом решения многих проблем. Однако они сложны в отладке, и с их помощью вы можете легко превратить код в хаос, в котором никто, даже вы, не разберётся.

Вы должны понимать:

  • Как исполнять корутины параллельно.
  • Как исполнять корутины последовательно.
  • Как создавать новые корутины из существующих.
  • Как создавать собственные корутины с помощью CustomYieldInstruction.

//Это сама корутина
IEnumerator RunInParallel()
{
   yield return StartCoroutine(Coroutine1());
   yield return StartCoroutine(Coroutine2());
}

public void RunInSequence()
{
   StartCoroutine(Coroutine1());
   StartCoroutine(Coroutine1());
}

Coroutine WaitASecond()
{
   return new WaitForSeconds(1);
}

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

В реализации ниже используется typeof вместо generic-версий этих функций. Generic-версии не работают с интерфейсами, а typeof работает. Представленный ниже метод оборачивает его в generic-методы.

public static TInterface GetInterfaceComponent(this Component thisComponent)
   where TInterface : class
{
   return thisComponent.GetComponent(typeof(TInterface)) as TInterface;
}

17. Используйте методы расширения (extension methods), чтобы сделать синтаксис более удобным. Например:

public static class TransformExtensions
{
   public static void SetX(this Transform transform, float x)
   {
      Vector3 newPosition =
         new Vector3(x, transform.position.y, transform.position.z);
 
      transform.position = newPosition;
   }
   ...
}

18. Используйте более «мягкую» альтернативу GetComponent. Иногда принудительное добавление зависимостей через RequireComponent может быть неприятным, оно не всегда возможно или приемлемо, в особенности когда вы вызываете GetComponent для чужого класса. В качестве альтернативы может использоваться следующее расширение GameObject, когда объект должен выдавать сообщение об ошибке, если он не найден.

public static T GetRequiredComponent(this GameObject obj) where T : MonoBehaviour
{
   T component = obj.GetComponent();
 
   if(component == null)
   {
      Debug.LogError("Ожидается компонент типа "
         + typeof(T) + ", но он отсутствует", obj);
   }
 
   return component;
}

19. Избегайте использования разных идиом для выполнения одинаковых действий. Во многих случаях существуют различные идиоматические способы выполнения действий. В таких случаях выберите одну идиому и используйте её для всего проекта. И вот почему:

  • Некоторые идиомы плохо совместимы. Использование одной идиомы направляет разработку в направлении, не подходящем для другой идиомы.

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

Примеры групп идиом:

  • Корутины — конечные автоматы.
  • Встроенные префабы — привязанные префабы — god-префабы.
  • Стратегии разделения данных.
  • Способы использования спрайтов для состояний в 2D-играх.
  • Структура префабов.
  • Стратегии спаунинга.
  • Способы нахождения объектов: по типу/имени/тегу/слою/ссылке.
  • Способы группировки объектов: по типу/имени/тегу/слою/массиву ссылок.
  • Способы вызова методов других компонентов.
  • Поиск групп объектов/self-registration.
  • Контроль порядка выполнения (использование настройки порядка выполнения Unity — yield-логики — Awake / Start и Update / Late Update — manual methods — произвольная архитектура
  • Выбор объектов / положений / целей в игре мышью: менеджер выбора — локальное самоуправление.
  • Хранение данных при смене сцен: через PlayerPrefs или с помощью объектов, которые не уничтожаются (Destroy) при загрузке новой сцены.
  • Способы сочетания (блендинг, добавление и наслаивание) анимации.
  • Обработка ввода (центральная — локальная)

20. Создайте и поддерживайте свой собственный класс времени, чтобы сделать работу с паузами удобнее. Оберните Time.DeltaTime и Time.TimeSinceLevelLoad для управления паузами и масштабом времени. Для использования класса требуется дисциплина, но он делает всё намного проще, в особенности при выполнении с различными счётчиками времени (например, анимации интерфейса и игровые анимации).

Дополнение: Unity поддерживает unscaledTime и unscaledDeltaTime, которые делают собственный класс времени избыточным во многих ситуациях. Но он всё равно может полезен, если масштабирование глобального времени влияет на компоненты, которые вы не писали нежелательными способами.

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

22. Используйте общую структуру для выполнения вызовов WWW. В играх с большим объёмом коммуникаций с сервером обычно существуют десятки вызовов WWW. Вне зависимости от того, используете ли вы сырой класс WWW Unity или плагин, удобно будет написать тонкий слой поверх, который будет работать как boilerplate.

Обычно я определяю метод Call (отдельно для Get и Post), корутину CallImpl и MakeHandler. В сущности, метод Call создаёт с помощью метода MakeHandler «суперобработчик» (super hander) из парсера, обработчик on-success и on-failure. Также он вызывает корутину CallImpl, которая формирует URL, выполняет вызов, ожидает его завершения, а потом вызывает «суперобработчик».

Вот как это приблизительно выглядит:

public void Call<T>(string call, Func<string, T> parser, Action<T> onSuccess, Action<string> onFailure)
{
	var handler = MakeHandler(parser, onSuccess, onFailure);
	StartCoroutine(CallImpl(call, handler));
} 

public IEnumerator CallImpl<T>(string call, Action<T> handler)
{
	var www = new WWW(call);
	yield return www;
	handler(www);
}

public Action<WWW> MakeHandler<T>(Func<string, T> parser, Action<T> onSuccess, Action<string> onFailure)
{
   return (WWW www) =>
   {
      if(NoError(www)) 
      {
         var parsedResult = parser(www.text);
         onSuccess(parsedResult);
      }
      else
      {
         onFailure("Текст ошибки");
      }
   }
}

У такого подхода есть несколько преимуществ.

  • Он позволяет избежать написания большого объёма boilerplate-кода
  • Он позволяет обрабатывать нужные элементы (например, отображение загружающегося компонента UI или обработка определённых общих ошибок) в первую очередь.

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

24. Если вы планируете локализацию, отделите все строки в одно место. Существует несколько способов сделать это. Один из них — это определить класс Text с строчным полем типа public для каждой строки, по умолчанию, например, будет установлен английский. Другие языки будут дочерними классам и повторно инициализируют поля с языковыми аналогами.

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

Дизайн классов


25. Решите, как будут использоваться инспектируемые поля, и сделайте это стандартом. Есть два способа: сделать поля public, или сделать их private и пометить как [SerializeField]. Последнее «более корректно», но менее удобно (и этот способ не очень популяризируется самой Unity). Что бы вы ни выбрали, сделайте это стандартом, чтобы разработчики в вашей команде знали, как интерпретировать поле public.

  • Инспектируемые поля являются public. В этом случае public означает: «переменная может безопасно изменяться дизайнером в процессе выполнения приложения. Не задавайте её значение в коде».

  • Инспектируемые поля являются private и помечены как Serializable. В этом случае public означает: «можно безопасно изменять эту переменную в коде» (поэтому их будет не очень много, а в MonoBehaviours и ScriptableObjects не будет полей public).

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

27. Используйте property drawers, чтобы сделать поля более удобными для пользователей. Property drawers можно использовать для настройки контролов (controls) в инспекторе. Это позволит вас создавать контролы, наиболее подходящие под вид данных и вставлять защиту (например ограничение значений переменных). Используйте атрибут Header для упорядочивания полей, а атрибут Tooltip — для предоставления дизайнерам дополнительной документации.

28. Отдавайте предпочтение property drawers, а не пользовательским редакторам (custom editors). Property drawers реализуются по типам полей, а значит, требуют гораздо меньше времени на реализацию. Их также удобнее использовать повторно – после реализации для типа их можно использовать для того же типа в любом классе. Пользовательские редакторы реализуются в MonoBehaviour, поэтому их сложнее использовать повторно и они требуют больше работы.

29. По умолчанию «запечатывайте» MonoBehaviours (применяйте модификатор sealed). В общем случае MonoBehaviours Unity не очень удобны для наследования:

  • Способ, которым Unity вызывает такие message-методы, как Start и Update, усложняет работу этих методов в подклассах. Если вы не будете внимательны, будет вызван не тот элемент, или вы забудете вызвать базовый метод.

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

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

public class MyBaseClass
{
   public sealed void Update()
   {
      CustomUpdate();
      ... // update этого класса 
   }

   //Вызывается до того, как этот класс выполняет свой update
   //Переопределение для выполнения вашего кода update.
   virtual public void CustomUpdate(){};
}

public class Child : MyBaseClass
{
   override public void CustomUpdate()
   {
      //Выполняем какие-то действия
   }
}

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

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

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

Вот урезанный пример компонента UI, позволяющего пользователю выбрать оружие из заданного списка. Единственное, что знают эти классы об игре, это класс Weapon (и только потому, что класс Weapon — полезный источник данных, которые этот контейнер должен отображать). Игра тоже ничего не знает о контейнере; ей нужно только зарегистрировать событие OnWeaponSelect.

public WeaponSelector : MonoBehaviour
{
   public event Action OnWeaponSelect {add; remove; } 
   //GameManager может регистрировать это событие

   public void OnInit(List  weapons)
   {
      foreach(var weapon in weapons)
      {

          var button = ... //Создаёт дочернюю кнопку и добавляет её в иерархию          
 
          buttonOnInit(weapon, () => OnSelect(weapon)); 
          // дочерняя кнопка отображает опцию, 
          // и отправляет сообщение о нажатии этому компоненту
      }
   }
   public void OnSelect(Weapon weapon)
  {
      if(OnWepaonSelect != null) OnWeponSelect(weapon);
   }
}

public class WeaponButton : MonoBehaviour
{
    private Action<> onClick;

    public void OnInit(Weapon weapon, Action onClick)
    {
        ... //установка спрайта и текста оружия

        this.onClick = onClick;
    }

    public void OnClick() //Привязываем этот метод как OnClick компонента UI Button
    {
       Assert.IsTrue(onClick != null);  //Не должно происходить

       onClick();
    }    
}

31. Разделите конфигурацию, состояние и вспомогательную информацию.

  • Переменные конфигурации — это переменные, настраиваемые в объекте для определения объекта через его свойства. Например, maxHealth.
  • Переменные состояния — это переменные, полностью определяющие текущее состояние объекта. Это переменные, которые необходимо сохранять, если ваша игра поддерживает сохранение. Например, currentHealth.
  • Вспомогательные (bookkeeping) переменные используются для скорости, удобства и переходных состояний. Они могут быть целиком определены из переменных состояния. Например, previousHealth.

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

public class Player
{
   [Serializable]
   public class PlayerConfigurationData
   {
      public float maxHealth;
   }

   [Serializable]
   public class PlayerStateData
   {
      public float health;
   }

   public PlayerConfigurationData configuration;
   private PlayerState stateData;

   //вспомогательная информация
   private float previousHealth;

   public float Health
   {
      public get { return stateData.health; }
      private set { stateData.health = value; }
   }
}

32. Не используйте связанные индексами массивы типа public. Например, не определяйте массив оружия, массив пуль и массив частиц таким образом:

public void SelectWeapon(int index)
{ 
   currentWeaponIndex = index;
   Player.SwitchWeapon(weapons[currentWeapon]);
}
 
public void Shoot()
{
   Fire(bullets[currentWeapon]);
   FireParticles(particles[currentWeapon]);
}

Проблема здесь скорее не в коде, а в сложности безошибочной настройки в инспекторе.

Лучше определите класс, инкапсулирующий все три переменные, и создайте из него массив:

[Serializable]
public class Weapon
{
   public GameObject prefab;
   public ParticleSystem particles;
   public Bullet bullet;
}

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

33. Избегайте использования массивов для структур, не являющихся последовательностями. Например, у игрока есть три типа атак. Каждая использует текущее оружие, но генерирует разные пули и разное поведение.

Вы можете попытаться засунуть три пули в массив, а затем использовать логику такого типа:

public void FireAttack()
{
   /// поведение
   Fire(bullets[0]);
}
 
public void IceAttack()
{
   /// поведение
   Fire(bullets[1]);
}
 
public void WindAttack()
{
   /// поведение
   Fire(bullets[2]);
}

Enums могут выглядеть красивее в коде…

public void WindAttack()
{
   /// behaviour
   Fire(bullets[WeaponType.Wind]);
}

…но не в инспекторе.

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

[Serializable]
public class Bullets
{
   public Bullet fireBullet;
   public Bullet iceBullet;
   public Bullet windBullet;
}

Это подразумевает, что других данных Fire, Ice и Wind нет.

34. Группируйте данные в сериализируемые классы, чтобы всё выглядело удобнее в инспекторе. Некоторые элементы могут иметь десятки настроек. Поиск нужной переменной может стать кошмаром. Чтобы упростить себе жизнь, следуйте этим инструкциям:

  • Определите отдельные классы для групп переменных. Сделайте их public и serializable
  • В основном классе определите public переменные каждого определённого выше типа.
  • Не инициализируйте эти переменные в Awake или Start; они сериализируемые, поэтому Unity позаботится о них сама.
  • Вы можете указать значения по умолчанию, назначив их при определении.

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

[Serializable]
public class MovementProperties //Не MonoBehaviour!
{
   public float movementSpeed;
   public float turnSpeed = 1; //указываем значение по умолчанию
}
 
public class HealthProperties //Не MonoBehaviour!
{
   public float maxHealth;
   public float regenerationRate;
}
 
public class Player : MonoBehaviour
{
   public MovementProperties movementProeprties;
   public HealthPorperties healthProeprties;
}

35. Сделайте не являющиеся MonoBehaviour классы Serializable, даже если они не используются для полей public. Это позволит просматривать поля класса в инспекторе, когда он находится в режиме Debug mode. Это работает и для вложенных классов (private или public).

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

public class Actor : MonoBehaviour
{
   public float initialHealth = 100;
   
   private float currentHealth;

   public void Start()
   {
      ResetState();
   }   

   private void Respawn()
   {
      ResetState();
   } 

   private void ResetState()
   {
      currentHealth = initialHealth;
   }
}

Паттерны


Паттерны — это способы решения часто возникающих проблем стандартными методами. Книга Роберта Нистрома «Паттерны программирования игр» (можно прочитать её бесплатно онлайн) — ценный ресурс для понимания того, как паттерны применимы для решения проблем, возникающих при разработке игр. В самой Unity есть множество таких паттернов: Instantiate — это пример паттерна «прототип» (prototype); MonoBehaviour — это версия паттерна «шаблонный метод» (template), в UI и анимации используется паттерн «наблюдатель» (observer), а новый движок анимации использует конечные автоматы (state machines).

Эти советы относятся к использованию паттернов конкретно в Unity.

37. Используйте для удобства синглтоны (паттерн «одиночка»). Следующий класс автоматически сделает синглтоном любой наследующий его класс:

public class Singleton<T> : MonoBehaviour where T : MonoBehaviour
{
   protected static T instance;
 
   //Возвращает экземпляр этого синглтона.
   public static T Instance
   {
      get
      {
         if(instance == null)
         {
            instance = (T) FindObjectOfType(typeof(T));
 
            if (instance == null)
            {
               Debug.LogError("В сцене нужен экземпляр " + typeof(T) + 
                  ", но он отсутствует.");
            }
         }
 
         return instance;
      }
   }
}

Синглтоны полезны для менеджеров, например для ParticleManager, AudioManager или GUIManager.

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

  • Не используйте синглтоны для уникальных экземпляров префабов, не являющихся менеджерами (например, Player). Придерживайтесь этого принципа, чтобы не усложнять иерархию наследования и внесение определённых типов изменений. Лучше храните ссылки на них в GameManager (или в более подходящем классе God ;-) ).
  • Определите свойства static и методы для переменных и методов public, которые часто используются за пределами класса. Это позволит вам писать GameManager.Player вместо GameManager.Instance.player.

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

38. Используйте конечные автоматы (state machines) для создания различного поведения в разных состояниях или для выполнения кода при смене состояний. Лёгкий конечный автомат имеет множество состояний и для каждого состояния вы можете указать действия, выполняемые при входе или нахождении в состоянии, а также действие обновления. Это позволить сделать код более чистым и менее подверженным ошибкам. Хороший признак того, что вам пригодится конечный автомат: код метода Update содержит конструкции if или switch, изменяющие его поведение, или такие переменные как hasShownGameOverMessage.

public void Update()
{
   if(health <= 0)
   {
      if(!hasShownGameOverMessage) 
      {
         ShowGameOverMessage();
         hasShownGameOverMessage = true; //При респауне значение становится false
      }
   }
   else
   {
      HandleInput();
   }   
}

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

39. Используйте поля типа UnityEvent для создания паттерна «наблюдатель» (observer) в инспекторе. Класс UnityEvent позволяет связывать методы, которые получают до четырёх параметров в испекторе, с помощью того же интерфейса UI, что и события в Buttons. Это особенно полезно при работе с вводом.

40. Используйте паттерн «наблюдатель», чтобы определять, когда изменяется значение поля. Проблема исполнения кода только при изменении переменной часто возникает в играх. Мы создали стандартное решение этой проблемы с помощью generic-класса, позволяющего регистрировать события изменения переменных. Ниже представлен пример со здоровьем. Вот как он создаётся:

/*Наблюдаемое значение*/ health = new ObservedValue(100);
health.OnValueChanged += () => { if(health.Value <= 0) Die(); };

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

if(hit) health.Value -= 10;

Когда здоровье становится ниже 0, вызывается метод Die. Подробные обсуждения и реализацию см. в этом посте.

41. Используйте для префабов паттерн Actor. (Это «нестандартный» паттерн. Основная идея взята из презентации Кирана Лорда (Kieran Lord).)

Actor (актор) — это основной компонент префаба. Обычно это компонент, обеспечивающий «индивидуальность» префаба, и тот, с которым наиболее часто будет взаимодействовать код более высокого уровня. Actor часто использует другие компоненты – помощники (helpers) – для того же объекта (и иногда для дочерних объектов), чтобы выполнить свою работу.

При создании объекта «кнопка» через меню Unity создаётся игровой объект с компонентами Sprite и Button (и дочерний с компонентом Text). В этом случае актором будет являться Button. Главная камера также обычно имеет несколько компонентов (GUI Layer, Flare Layer, Audio Listener), прикреплённые к компоненту Camera. Camera здесь является актором.

Для правильной работы актора могут потребоваться другие компоненты. Можно сделать префаб более надёжным и полезным с помощью следующих атрибутов компонента-актора:

  • Используйте RequiredComponent для указания всех компонентов, которые требуются актору в том же игровом объекте. (Актор тогда сможет всегда безопасно вызывать GetComponent без необходимости проверки, не было ли возвращённое значение null.)
  • Используйте DisallowMultipleComponent, чтобы избежать прикрепления нескольких экземпляров того же компонента. Актор всегда сможет вызвать GetComponent, не беспокоясь о поведении, которое должно быть, когда прикреплено несколько компонентов).
  • Используйте SelectionBase, если у объекта-актора есть дочерние объекты. Так его будет проще выбрать в окне сцены.

[RequiredComponent(typeof(HelperComponent))]
[DisallowMultipleComponent]
[SelectionBase]
public class Actor : MonoBehaviour
{
   ...//
}

42. Используйте генераторы случайных и паттернизированных потоков данных. (Это нестандартный паттерн, но мы считаем его чрезвычайно полезным.)

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

Вот несколько примеров:

var generator = Generator
   .RamdomUniformInt(500)
   .Select(x => 2*x); //Генерирует чётные числа от 0 до 998
 
var generator = Generator
   .RandomUniformInt(1000)
   .Where(n => n % 2 == 0); //Делает то же самое
 
var generator = Generator
    .Iterate(0, 0, (m, n) => m + n); //Числа Фибоначчи
 
var generator = Generator
   .RandomUniformInt(2)
   .Select(n => 2*n - 1)
   .Aggregate((m, n) => m + n); //Случайные скачки с шагом 1 или -1
 
var generator = Generator
   .Iterate(0, Generator.RandomUniformInt(4), (m, n) => m + n - 1)
   .Where(n >= 0); //Случайная последовательность, увеличивающая среднее

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

while (true)
{
   //Что-то делаем
   
   yield return new WaitForSeconds(timeIntervalGenerator.Next());
}

Прочитайте этот пост, чтобы больше узнать о генераторах.

Префабы и скриптуемые объекты


43. Используйте префабы для всего. Единственными игровыми объектами в сцене, которые не являются префабами (или частями префабов), должны быть папки. Даже уникальные объекты, которые используются только один раз, должны быть префабами. Это упрощает внесение изменений, не требующих изменения сцены.

44. Привязывайте префабы к префабам; не привязывайте экземпляры к экземплярам. Связи с префабами сохраняются при перетаскивании префаба в сцену, связи с экземплярами — нет. Привязывание к префабам там, где это возможно, уменьшает затраты на настройку сцены и снижает необходимость изменения сцен.

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

45. Не делайте сетки корнями префабов, если вы хотите добавлять другие скрипты. При создании префаба из сетки сначала сделайте родителем сетки пустой игровой объект, и пусть он будет корнем. Привязывайте скрипты к корню, а не к сетке. Так вам будет проще заменить сетку другой сеткой, не теряя значения, настроенные в инспекторе.

46. Используйте скриптуемые объекты, а не префабы для передаваемых данных конфигурации.

Если вы так сделаете:

  • сцены будут меньше
  • вы не сможете по ошибке внести изменения в одну сцену (в экземпляр префаба)

47. Используйте скриптуемые объекты для данных уровней. Данные уровней часто хранятся в XML или JSON, но использование вместо них скриптуемых объектов имеет ряд преимуществ:

  • Их можно будет редактировать в Editor. Так проще будет проверять данные, и такой способ более удобен для нетехнических дизайнеров. Более того, вы можете использовать пользовательские редаторы, чтобы редактировать их было ещё проще.
  • Вам не нужно будет волноваться о чтении/записи и парсинге данных.
  • Разделение и встраивание, а также управление получившимися ассетами станут проще. Так вы сможете создавать уровни из строительных блоков, а не из массивной конфигурации.

48. Используйте скриптуемые объекты для конфигурирования поведения в инспекторе. Скриптуемые объекты обычно связаны с данными конфигурирования, но они также позволяют использовать «методы» как данные.

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

Вот как это работает:

public class Enemy : MonoBehaviour
{
   public SuperPower superPowers;

   public UseRandomPower()
   {
       superPowers.RandomItem().UsePower(this);
   }
}

public class BasePower : ScriptableObject
{
   virtual void UsePower(Enemy self)
   {
   }
}

[CreateAssetMenu("BlowFire", "Blow Fire")
public class BlowFire : SuperPower
{
   public strength;
   override public void UsePower(Enemy self)
   {
      ///программа использования суперсилы blow fire
   }
}

При использовании этого паттерна не нужно забывать о следующих ограничениях:

  • Скриптуемые объекты не могут надёжно быть абстрактными. Вместо этого используйте конкретные базовые глассы и выдавайте NotImplementedExceptions в методах, которые должны быть абстрактными. Также можно определить атрибут Abstract и отметить им классы и методы, которые должны быть абстрактными.

  • Generic скриптуемые объекты не могут быть сериализированы. Однако можно использовать generic базовые классы и сериализировать только подклассы, определяющие все generic.

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

Это обеспечивает бoльшую гибкость:

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

Вот простой пример такой настройки.

[CreateAssetMenu("HealthProperties.asset", "Health Properties")]
public class HealthProperties : ScriptableObject
{
   public float maxHealth;
   public float resotrationRate;
}

public class Actor : MonoBehaviour
{
   public HealthProperties healthProperties;
}

При большом количестве специализаций можно определить специализацию как обычный класс, и использовать их список в скриптуемом объекте, связанном с подходящим местом, в котором можно его применять (например, в GameManager). Для обеспечения его безопасности, скорости и удобства потребуется немного больше «клея»; ниже приведён пример минимально возможного использования.

public enum ActorType
{
   Vampire, Wherewolf
}

[Serializable]
public class HealthProperties
{
   public ActorType type;
   public float maxHealth;
   public float resotrationRate;
}

[CreateAssetMenu("ActorSpecialization.asset", "Actor Specialization")]
public class ActorSpecialization : ScriptableObject
{
   public List healthProperties;

   public this[ActorType]
   {
       get { return healthProperties.First(p => p.type == type); } //Небезопасная версия!
   }
}

public class GameManager : Singleton  
{
   public ActorSpecialization actorSpecialization;

   ...
}

public class Actor : MonoBehaviour
{
   public ActorType type;
   public float health;
   
   //Пример использования
   public Regenerate()
   {
      health 
         += GameManager.Instance.actorSpecialization[type].resotrationRate;
   }
}

50. Используйте атрибут CreateAssetMenu чтобы автоматически добавить создание ScriptableObject в меню Asset/Create.

Отладка


51. Научитесь эффективно использовать инструменты отладки Unity.

  • Добавляйте объекты context в конструкции Debug.Log, чтобы знать, где они генерируются.
  • Используйте Debug.Break для паузы игры в редакторе (например, это полезно, когда вы хотите выполнить условия ошибки и вам нужно исследовать свойства компонента в этом кадре).
  • Используйте функции Debug.DrawRay и Debug.DrawLine для визуальной отладки (например, DrawRay очень полезна при отладке причин «непопадания» ray cast).
  • Используйте для визуальной отладки Gizmos. Можно также использовать gizmo renderer за пределами mono behaviours с помощью атрибута DrawGizmo.
  • Используйте инспектор в режиме отладки (чтобы видеть с помощью инспектора значения полей private при выполнении приложения в Unity).

52. Научитесь эффективному использованию отладчика вашей IDE. См., например, Debugging Unity games in Visual Studio.

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

54. Пользуйтесь удобной записью в консоль. Используйте расширение редактора, позволяющее кодировать цветом вывод по категориям и фильтровать вывод согласно этим категориям. Мы применяем Editor Console Pro, но есть и другие расширения.

55. Используйте инструменты тестирования Unity, особенно для тестирования алгоритмов и математического кода. См., например, туториал Unity Test Tools или пост Unit testing at the speed of light with Unity Test Tools.

56. Применяйте инструменты тестирования Unity для выполнения «черновых» тестов. Инструменты тестирования Unity пригодны не только для формальных тестов. Их также можно использовать для удобных «черновых» тестов, которые выполняются в редакторе без запуска сцены.

57. Используйте горячие клавиши, чтобы делать скриншоты. Многие баги связаны с визуальным отображением, и гораздо проще сообщать о них, если можно сделать снимок экрана. Идеальная система должна иметь счётчики PlayerPrefs, чтобы скриншоты не перезаписывались. Скриншоты не нужно сохранять в папке проекта, чтобы сотрудники случайно не фиксировали (commit) их в репозитории.

58. Используйте горячие клавиши для печати снепшотов важных переменных. Они позволят регистрировать информацию, когда во время игры происходит неожиданные события, которые можно исследовать. Набор переменных конечно же зависит от игры. Подсказками для вас могут стать типичные ошибки, возникающие в игре. Например, положения игрока и врагов или «состояние думания» AI-актора (скажем, путь, по которому он пытается следовать).

59. Реализуйте опции отладки, чтобы упростить тестирование. Примеры:

  • Разблокировать все предметы.
  • Отключить врагов.
  • Выключить GUI.
  • Сделать игрока неуязвимым.
  • Отключить весь игровой процесс.

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

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

61. При процедурной генерации сеток отрисовывайте или спауньте небольшие сферы в вершинах. Это позволит вам убедиться, что вершины находятся в нужных местах и они нужного размера, прежде чем начинать работу с треугольниками и UV для отображения сеток.

Производительность


62. Будьте осторожны с общими рекомендациями по дизайну и структуре для обеспечения производительности.

  • Часто такие советы основаны на мифах и не проверены тестами.
  • Иногда рекомендации проверены тестами, но тесты некачественны.
  • Бывает, что советы проверены качественными тестами, но они нереалистичны или применимы в другом контексте. (Например, можно просто доказать, что использование массивов быстрее, чем generic lists. Однако в контексте реальной игры эта разница почти всегда незначительна. Ещё можно добавить, что если тесты проводились на оборудовании, отличающемся от целевых для вас устройств, то их результаты могут быть в вашем случае бесполезны.)
  • Иногда совет бывает правильным, но уже устаревшим.
  • Иногда рекомендация полезна. Но тут может возникнуть необходимость компромисса: иногда медленные, но выполненные в срок игры лучше, чем быстрые, но запаздывающие. Сильно оптимизированные игры могут с большей вероятностью содержать хитрый код, задерживающий релиз.
  • Полезно учитывать советы по обеспечению производительности, чтобы находить источники истинных проблем быстрее, чем описанным выше процессом.

63. Как можно раньше начинайте регулярно тестировать игру на целевых устройствах. Устройства имеют разные характеристики производительности; не позволяйте им подкидывать вам сюрпризы. Чем раньше вы узнаете о проблемах, тем более эффективно вы сможете их решать.

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


65. При необходимости используйте сторонний профайлер для более точного профайлинга. Иногда профайлер Unity не может предоставить чёткую картинку происходящего: у него могут закончиться кадры профайла, или глубокий профайлинг настолько тормозит игру, что результаты тестов не имеют смысла. В этом случае мы используем наш собственный профайлер, но вы можете найти альтернативные в Asset Store.

66. Замеряйте эффект улучшений производительности. При внесении изменений для повышения производительности замеряйте её, чтобы убедиться, что изменение действительно улучшает производительность. Если изменение неизмеряемо или незначительно, откажитесь от него.

67. Не пишите менее читаемый код для повышения производительности. Исключения:

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

    ИЛИ
  • Вы точно знаете, что делаете.

Стандарт присвоения имён и структура папок


68. Следуйте задокументированным соглашению о присвоении имён и структуре папок. Благодаря стандартизированному присвоению имён и структуре папок проще искать объекты и разбираться в них.

Скорее всего, вы захотите создать своё собственное соглашение о присвоении имён и структуру папок. Вот одно для примера.

Общие принципы присвоения имён


  1. Называйте вещи своими именами. Птица должна называться Bird.
  2. Выбирайте имена, которые можно произнести и запомнить. Если вы делаете игру про майя, не называйте уровень QuetzalcoatisReturn (ВозвращениеКетцалкоатля).
  3. Поддерживайте постоянство. Если вы выбрали имя, придерживайтесь его. Не называйте что-то buttonHolder в одном случае и buttonContainer в другом.
  4. Используйте Pascal case, например: ComplicatedVerySpecificObject. Не используйте пробелы, символы подчёркивания или дефисы, с одним исключением (см. раздел «Присвоение имён для различных аспектов одного элемента»).
  5. Не используйте номера версий или слова для обозначения степени выполнения (WIP, final).
  6. Не используйте аббревиатуры: DVamp@W должен называться DarkVampire@Walk.
  7. Используйте терминологию дизайн-документа: если в документе анимация смерти называется Die, то используйте DarkVampire@Die, а не DarkVampire@Death.
  8. Оставляйте наиболее конкретное описание слева: DarkVampire, а не VampireDark; PauseButton, а не ButtonPaused. Например, будет проще найти кнопку паузы в инспекторе, если не все названия кнопок начинаются со слова Button. [Многие предпочитают обратный принцип, потому что так группировка визуально выглядит более очевидной. Однако имена, в отличие от папок, не предназначены для группировки. Имена нужны для различения объектов одного типа, чтобы можно было находить их быстро и просто.]
  9. Некоторые имена образуют последовательности. Используйте в этих именах числа, например, PathNode0, PathNode1. Всегда начинайте нумерацию с 0, а не с 1.
  10. Не используйте числа для элементов, не образующих последовательность. Например, Bird0, Bird1, Bird2 должны называться Flamingo, Eagle, Swallow.

Присвоение имён для различных аспектов одного элемента


Используйте символы подчёркивания между основным именем и частью, описывающей «аспект» элемента. Например:

  • Состояния кнопок GUI EnterButton_Active, EnterButton_Inactive
  • Текстуры DarkVampire_Diffuse, DarkVampire_Normalmap
  • Скайбоксы JungleSky_Top, JungleSky_North
  • Группы LOD DarkVampire_LOD0, DarkVampire_LOD1

Не используйте это соглашение для различения разных типов элементов, например, Rock_Small, Rock_Large должны называться SmallRock, LargeRock.

Структура


Схема сцен, папки проекта и папки скриптов должна иметь похожий шаблон. Ниже представлены примеры, которые можно использовать.

Структура папок


MyGame
Helper
Design
Scratchpad
Materials
Meshes
Actors
DarkVampire
LightVampire

Structures
Buildings

Props
Plants


Resources
Actors
Items

Prefabs
Actors
Items

Scenes
Menus
Levels
Scripts
Tests
Textures
UI
Effects

UI
MyLibray

Plugins
SomeOtherAsset1
SomeOtherAsset2
...

Структура сцены


Main
Debug
Managers
Cameras
Lights
UI
Canvas
HUD
PauseMenu

World
Ground
Props
Structures

Gameplay
Actors
Items

Dynamic Objects

Структура папок скриптов


Debug
Gameplay
Actors
Items

Framework
Graphics
UI
...
Поделиться с друзьями
-->

Комментарии (18)


  1. Terras
    14.10.2016 16:36

    Крутой обзор.

    Под какую платформу делаете игры? Делаете в рамках команды или единолично?


  1. Vilyx
    14.10.2016 16:45

    По своему опыту скажу, что вместо:
    2. Сделайте каждую сцену запускаемой.
    лучше сделать тестовую сцену, которая будет создавать передаваемый объект, если такой нужен для запуска целевой сцены, а затем загружать целевую сцену.
    Использование синглтонов лучше свести к минимуму, использовать их только в том случае когда надо предоставить доступ к эксклюзивным ресурсам, таким как воспроизведение звука.
    А чтобы передать данные из сцену в сцену подойдут неразрушаемые объекты.
    Синглтоны плохи тем, что они остаются в памяти даже тогда, когда уже и не нужны, а убрать их оттуда сложнее чем разрушить объект.


    1. Leopotam
      14.10.2016 19:07

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

      Да неужели? Уже ведь была обсосана тема на хабре про синглтоны и их время жизни. Пример в этой статье — плохой, отсутствует механизм проверки существования инстанса для учета, например, в OnDestroy и тп деструктивных эвентах. Более «прямой» вариант можно посмотреть тут: можно узнать, создан ли синглтон, можно сделать обработку Awake-а для одного инстанса (а не для всех попыток создания копию), по умолчанию это локальный для сцены синглтон (умрет при смене сцены корректно), если требуется глобальный — можно в перегрузке OnConstruct() добавить DontDestroyOnLoad (полезно для всяких глобальных провайдеров данных и сетевых решений).


      1. Vilyx
        14.10.2016 22:01

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


        1. Leopotam
          14.10.2016 22:14

          Поспорить? Я вроде как процитировал то, к чему писал комментарий, где там было про передачу данных между сценами? И да, в этом синглтон без привязки к данным сцены тоже неплох (например, хранение настроек клиента игрока, его прогресса, для централизованного сохранения и использования в качестве контейнера для данных, которые требуются во всех сценах), ибо возможна реакция на события жизненного цикла MonoBehaviour, например, можно поймать момент загрузки сцены. Вот повсеместное и бездумное использование статик-классов считаю вредным, за редким исключением — синглтоны довольно просто перепиливаются под отдельный инстанс, а вот статику так просто не переделать.


          1. Vilyx
            14.10.2016 22:52

            Хранение настроек клиента это не передача данных между сценами. Это две разные функции. Про статик согласен.


            1. Leopotam
              14.10.2016 23:12
              +1

              namespace Client.Common {
                  sealed class PlayerManager : UnitySingleton<PlayerManager> {
                      public PlayerSettings Settings { get; private set; }
              
                      public PlayerProgress Progress { get; private set; }
              
                      public PlayerSession Session { get; private set; }
              
                      protected override void OnConstruct () {
                          base.OnConstruct ();
                          DontDestroyOnLoad (gameObject);
              
                          Session = new PlayerSession ();
              
                          LoadSettings ();
                          SaveSettings ();
              
                          LoadProgress ();
                          SaveProgress ();
                      }
              
                      void LoadSettings () {
                          // грузим Settings из PlayerPrefs или из Persistent-хранилища
                      }
              
                      public void SaveSettings () {
                          // сохраняем Settings в PlayerPrefs или в Persistent-хранилище
                      }
              
                      public void LoadProgress () {
                          // грузим локальный прогресс
                      }
              
                      public void SaveProgress () {
                          // сохраняем локальный прогресс
                      }
                  }
              }
              
                  sealed class PlayerSettings {
                      [JsonName ("o1")]
                      public float SoundVolume = 1f;
              
                      [JsonName ("o2")]
                      public float MusicVolume = 1f;
                  }
              
              // по аналогии - другие классы-холдеры данных, с Json-сериализацией.
              

              Смысл в том, что не нужно ничего никуда вешать — просто потрогать инстанс этого провайдера данных и он будет сам следить за собой + предоставлять централизованное апи для работы с кросс-сценовыми данными. PlayerSettings — настройки пользователя (звук и прочее), PlayerProgress — если есть локальная кампания в синглплеере, то данные можно хранить и предоставлять так, PlayerSession — данные текущей игровой сессии, должны сбрасываться при смерти игрока и начале новой сессии. Как-то так. Т.е. основной смысл — хранить данные, по которым уже можно выполнять генерацию контента в каждой отдельно взятой сцене. Можно сделать и на pure-C# классах и как-то кидать линки, но если нужна реакция на события жизненного цикла MonoBehaviour, то это самый простой способ.


  1. shai_hulud
    14.10.2016 20:39
    +1

    Ммм, 2016год синглтоны в «советах и рекомендациях». Сами по себе синглтоны как паттерн времени жизни объекта не так уж и плохи, но советовать делать глобальные объекты с состоянием (XXXManager, XXXController etc) это уже вредительство.

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


    1. Vilyx
      14.10.2016 22:13

      Исключая синглтоны, вполне дельные советы, не считая пары тех, сути которых я вообще не понял.


      1. shai_hulud
        14.10.2016 23:28

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

        Как пример: писать в штаны удобно, и снимать не надо, и в начале тепло. Но в долгосрочной перспективе этот совет не очень.


  1. WeslomPo
    15.10.2016 09:54
    +1

    2. Делай каждую сцену запускаемой: Не всегда возможно, к этому стоит стремиться, но в реальности проще сделать объект Persistent запихнуть его в префаб а потом скидывать на все сцены, а сам объект инициализировать в первой сцене с логотипом.
    5. Обновляйся одновременно с командой: Выполняйте обновление юнити со страхом, а лучше прочитайте что это вам даст, оцените стоит ли это того, может и текущая версия неплоха. Обязательно читайте release notes и исправления на странице выпуски патчей. Возможно та неуловимая проблема на каком-нибудь редком устройстве которое вы уже пару месяцев не можете поймать, уже исправлена в патче для вашей версии? Но и обновляйтесь со страхом, получить ещё много новых занимательных неуловимых проблем, которые будут исправлены следующим патчем.
    6. Импортируй ассеты в чистый проект: Хорошая практика, плюс заведите папку addons или extensions и все-все-все сторонние дополнения старайтесь сохранить в ней. Это уменьшит путаницу в иерархии папок.
    9. Используй пространство имён с умом: Пространство имён, штука классная, но и с ними бывают коллизии, избегайте имён типа Utils каждое второе дополнение имеет его, лучше начинать пространство имён с, например, названия компании, WeslomPo.Utils — имеет меньше шансов столкнуться с другим пространством (так принято называть классы в других языках программирования, например Java или AS3, только ещё более жёстко: ru.weslompo.projectname.etc). Папки в должны следовать пространству имён. Т.е. файл лежащий в корне «Scripts» не имеет пространства имён, а файл «Scripts\Puzzle\Pieces\PieceOfCake.cs» должен иметь пространство имён «Puzzle.Pieces».
    25. Инспектируемые поля только private + [SerializeField]: Я бы был более настойчивым в утверждении что следует использовать только private поля + [SerializeField] для всего что можно, кроме того что нужно вызывать снаружи класса. Не смотря на то что юнитеки этот метод не пропагандируют (я думаю им просто лень писать это в примерах). Это делает классы чуть более защищенным от нежелательных изменений, и помогает меньше заглядывать внутрь кода чтобы определить что можно с ним делать а что нельзя. Плюс это просто хорошая практика (good practice).
    29. Запечатывай MonoBehaviour: Стоит дополнить, что sealed классы работают чуть быстрее чем не sealed, особенно когда проект компилируется в il2cpp, недавно была статья по этой теме.
    43. Используй префабы для всего: Стандартные префабы в юнити это пи… ужас-ужас. Постоянно с ними какие-нибудь гадости творятся (например нет поддержки вложенности, встроили префаб в префаб — потерял ссылку, обещают исправить… но пока ещё нету стабильной версии с этой фичей). Поэтому используйте префабы на свой страх и риск, и лучшее только для объектов которые используются ОДИН раз, тогда вы избежите нежелательного поведения.
    46-50. Советы по работе со ScriptableObject: «скриптуемые объекты» — я думаю это, как переводчик любит выражаться, «более сильно непонятно» чем ScriptableObject, особенно для тех кто никогда не трогал его ни разу в жизни, и даже не может понять что гуглить чтобы пояснить этот момент.
    Без нумерации: Структура папок, имхо излишне усложнённая, на первом уровне иерархии нужно иметь как можно меньше папок, и всё что является assets (Props, Prefabs, Texures, Meshes, Materials etc) на самом деле лучше засунуть куда-нибудь ниже, например, в папку «Data\», и там уже делать разветвлённую иерархию (DarkVampire, LightVampire, Plants etc.). Близкая к идеалу структура:
    image
    (Addons, Data, Editor, Plugins, Resources, Scenes, Scripts).
    Но и тут заметно что Soomla вылезла в коернь, потому что пути у неё захардкожены (их исправление приводит к неопределённому поведению).
    Еще стоит добавить обязательную установку расширения RainbowFolders. Прям вот начал проект, первым делом установил. Сидит в редакторе, в билд не попадает, а наглядность папок возрастает.
    Структура сцены излишне оптимистичная, ибо большая часть экстеншенов любит «срать» на сцену своими компонентами, либо хотят быть самыми близкими к корню (из-за метода DontDestroyOnLoad). Возможно меня закидают палками, но я обычно выпиливаю этот метод из всех нужных мне компонентов-дополнений. Просто завёл один объект Persistent который в единственном экземпляре использует метод DontDestroyOnLoad и самоудаляется если находит ещё один такой компонент на сцене. А все компоненты запихиваю в него ниже по иерархии с группировкой по типа (Advertise, Shop, Analytics etc.). Так получается чище сцена. А то посмотришь на неё как-нибудь, и ужаснёшься какой там кошмар устроили сторонние разработчики своими объектами без спросу.


    1. Leopotam
      15.10.2016 10:56

      заведите папку addons или extensions и все-все-все сторонние дополнения старайтесь сохранить в ней.

      К сожалению это часто невозможно, т.к. в плагинах прошиты абсолютные пути от Assets или используется папка Plugins/Android или Plugins/iOS — юнити смотрит платформозависимые вещи только в корневой папке.


      1. WeslomPo
        15.10.2016 11:14

        Я написал об этом. Обычно после обновления нахожу переменную ответственную за путь и меняю на соответствующий (это легко правится в google ads например). Но Soomla, как видно, грешит тем что исправление этой переменной не помогает, и вызывает неопределённое поведение иногда, потому лежит в корне. В общем, рекомендация, скорее звучит держать папку Assets как можно чище.


        1. Leopotam
          15.10.2016 11:32

          Обычно после обновления нахожу переменную ответственную за путь и меняю на соответствующий

          Это адская боль при апдейте ассетов, особенно если их больше 3-4.

          вызывает неопределённое поведение иногда

          я написал, почему так и это не фиксится, ибо специфика работы текущей версии юнити. Оно заработает как только юнитеки будут брать платформозависимые папки Android/iOS из любой вложенности иерархии проекта (как сейчас делается для Editor) — на данный момент все постбилд-скрипты должны лежать в /Plugins/<платформа> папке.


          1. WeslomPo
            15.10.2016 11:59

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


      1. demminik
        15.10.2016 12:36

        Существует и иной подход: создать папку для проекта внутри Assets и работать в ней.



  1. Tutanhomon
    18.10.2016 12:16
    +1

    IEnumerator RunInParallel()
    {
       yield return StartCoroutine(Coroutine1());
       yield return StartCoroutine(Coroutine2());
    }
    
    public void RunInSequence()
    {
       StartCoroutine(Coroutine1());
       StartCoroutine(Coroutine1());
    }
    

    Мне кажется, или всё наоброт?