Ошибки новичка Unity, испытанные на собственной шкуре
Привет, Хабр. Это снова я, Илья Кудинов, QA-инженер из компании Badoo. Но сегодня я расскажу не о тестировании (этим я уже занимался в понедельник), а о геймдеве. Нет, этим мы в Badoo не занимаемся, разрабатывать компьютерные игры — моё хобби.
Профессионалы индустрии, не судите строго. Все советы в этой статье адресованы начинающим разработчикам, решившим попробовать свои силы в Unity. Многие советы можно отнести к разработке в целом, но я постараюсь добавить в них Unity-специфику. Если вы можете посоветовать что-то лучше, чем предлагаю я — пишите в комментариях, буду обновлять статью.
Я мечтал разрабатывать игрушки с детства. Наверное, уже в далёком 1994 году, когда мне подарили мою первую Dendy, я думал:»Как была бы здолава, если бы вот в этай иглушке было бы ещё всякое классное…» В средней школе я начал учиться программировать и вместе с товарищем делал свои первые играбельные поделки (ох, как мы их любили!). В институте мы с друзьями строили наполеоновские планы о кардинальном изменении индустрии с помощью нашей совершенно новой темы…
А в 2014 году я начал изучать Unity и наконец-то НА САМОМ ДЕЛЕ начал делать игры. Однако вот беда: я никогда не работал программистом. У меня не было опыта настоящей корпоративной разработки (до этого я всё делал «на коленке», и, кроме меня, в моём коде никто бы не разобрался). Я умел программировать, но я не умел делать это хорошо. Все мои знания Unity и C# ограничивались скудными ещё на тот момент официальными туториалами. А мой любимый способ познавать мир — делать ошибки и учиться на них. И я наделал их предостаточно.
Сегодня я расскажу о некоторых из них и покажу, как их избежать (ах, если бы я знал всё это три года назад!)
Для того чтобы понять все используемые в материале термины, достаточно предварительно пройти один-два официальных туториала Unity. Ну, и иметь хоть какое-то представление о программировании.
Не засовывайте всю логику объекта в один MonoBehaviour
Ах, мой класс MonsterBehaviour
в нашей дебютной игре! 3200 строк спагетти-кода в его худшие дни. Каждая необходимость вернуться к этому классу вызывала у меня лёгкую дрожь, и я всегда старался отложить эту работу так надолго, как только мог. Когда спустя чуть больше года после его создания я-таки добрался до его рефакторинга, я не только разбил его на базовый класс и несколько наследников, но и вынес несколько блоков функционала в отдельные классы, которые добавлял в объекты прямо из кода с помощью gameObject.AddComponent()
, поэтому мне не пришлось изменять уже накопившиеся префабы.
Было:
монструозный класс MonsterBehaviour
, хранивший в себе все персональные настройки монстров, определявший их поведение, анимацию, прокачку, нахождение пути и всё-всё-всё.
Стало:
- абстрактный класс
MonsterComponent
, от которого наследуются все прочие компоненты и который занимается их связыванием и, к примеру, базовой оптимизацией в виде кеширования результатов вызоваgameObject.GetComponent
(); - класс
MonsterStats
, в который геймдизайнер заносит параметры монстров. Он их хранит, изменяет с уровнем и отдаёт другим классам по запросу; - класс
MonsterPathFinder
, который занимается поиском путей и хранит в статических полях сгенерированные данные для оптимизации алгоритма; - абстрактный класс
MonsterAttack
с наследниками под разные виды атаки (оружием, когтями, магией…), которые контролируют всё, что касается боевого поведения монстра — тайминги, анимацию, применение особых приёмов; - ещё много дополнительных классов, реализующих всяческую специфическую логику.
За несколько часов работы я смог урезать несколько сотен строк трудноподдерживаемого кода и сэкономить часы нервного копания в го плохом коде.
Что, суть моего совета в том, чтобы не писать гигантские классы, спасибо, Кэп? Нет. Мой совет: дробите вашу логику на атомарные классы ещё до того, как они станут большими. Пусть сначала ваши объекты будут иметь три-четыре осмысленных компонента по десятку строк в коде каждого, но ориентироваться в них будет не сложнее, чем в одном из 50 строк, зато при дальнейшем развитии логики вы не окажетесь в такой ситуации, как я. Заодно появляется больше возможностей для переиспользования кода — например, компонент, отвечающий за здоровье и получение урона, можно прилепить и игроку, и противникам, и даже препятствиям.
Умный термин — Interface segregation principle.
Не забывайте про ООП
Каким бы простым ни казалось на первый взгляд проектирование объектов в Unity («Программирование мышкой, фуууу»), не нужно недооценивать эту составляющую разработки. Да-да, я вот недооценивал. Прямо по пунктам:
- Наследование. Всегда приятно вынести какую-то общую логику нескольких классов в общий базовый класс. Иногда это имеет смысл сделать заранее, если объекты «идеологически» похожи, пусть и не имеют пока общих методов. Например, сундуки на уровне и декоративные факелы на стенах поначалу не имели ничего общего. Но когда мы начали разрабатывать механику тушения и зажигания факелов, пришлось выносить из сундуков в общий класс механику взаимодействия с ними игрока и показ подсказок в интерфейсе. А мог бы и сразу догадаться. А ещё у меня есть общий базовый класс для всех объектов, являющийся надстройкой над MonoBehaviour, с кучкой полезных новых функций.
- Инкапсуляция. Даже не буду объяснять, насколько полезной может быть установка правильных областей видимости. Упрощает работу, снижает вероятность глупой ошибки, позволяет удобнее дебажиться… Здесь ещё полезно знать про две директивы —
[HideInInspector]
, скрывающую в инспекторе публичные поля компонента, которые не стоит править в объектах (впрочем, имеет смысл по возможности вообще избегать публичных полей, это плохая практика), и[SerializeField]
, напротив, отображающую в инспекторе приватные поля (что бывает очень полезно для более удобного дебага). - Полиморфизм. Здесь вопрос исключительно в красоте и лаконичности кода. Одна из моих любимых штук для поддержки полиморфизма в C# — универсальные шаблоны. Например, я написал такие простые и удобные методы для выдёргивания случайного элемента произвольного класса из L
ist
(а делаю я это очень часто):
protected T GetRandomFromList(List list)
{
return list[Random.Range(0, list.Count)];
}
protected T PullRandomFromList(ref List list)
{
int i = Random.Range(0, list.Count);
T result = list[i];
list.RemoveAt(i);
return result;
}
При этом C# — такая душка, что позволяет не плодить эти параметры, и вот эти два вызова будут работать идентично:
List list = new List();
ExampleClass a = GetRandomFromList(list);
ExampleClass a = GetRandomFromList(list);
Умный термин — Single responsibility principle.
Изучите Editor GUI
Я этим занялся значительно позже, чем стоило. Я уже писал статью о том, как это может помочь при разработке как программисту, так и геймдизайнеру. Помимо кастомных инспекторов для отдельных атрибутов и целых компонентов, Editor GUI можно использовать для огромного количества вещей. Создавать отдельные вкладки редактора для просмотра и изменения SAVE-файлов игры, для редактирования сценариев, для создания уровней… Возможности — безграничны! Да и потенциальная экономия времени просто восхитительна.
Думайте о локализации с самого начала
Даже если вы не уверены, что будете переводить игру на другие языки. Впиливать локализацию в уже сформировавшийся проект — невыносимая боль. Можно придумать самые разные способы локализации и хранения переводов. Жаль, что Unity не умеет самостоятельно выносить все строки в отдельный файл, который поддаётся локализации «из коробки» и без доступа к остальному коду приложения (как, например, в Android Studio). Вам придётся писать такую систему самому. Лично я использую для этого два решения, пусть и не очень изящные.
Оба они базируются на моём собственном классе TranslatableString
:
[System.Serializable]
public class TranslatableString
{
public const int LANG_EN = 0;
public const int LANG_RU = 1;
public const int LANG_DE = 2;
[SerializeField] private string english;
[SerializeField] private string russian;
[SerializeField] private string german;
public static implicit operator string(TranslatableString translatableString)
{
int languageId = PlayerPrefs.GetInt("language_id");
switch (languageId) {
case LANG_EN:
return translatableString.english;
case LANG_RU:
return translatableString.russian;
case LANG_DE:
return translatableString.german;
}
Debug.LogError("Wrong languageId in config");
return translatableString.english();
}
}
В нём ещё есть кучка строк с защитой от ошибок и проверки на заполненность полей, сейчас я их убрал для читабельности. Можно хранить переводы как массив, но по ряду причин я всё же выбрал отдельные поля.
Вся «магия» — в методе неявного преобразования в строку. Благодаря ему вы в любом месте кода можете вызвать что-то типа такого:
TranslatableString lexeme = new TranslatableString();
string text = lexeme;
— и сразу же получить в строке text нужный перевод в зависимости от текущего языка в настройках игрока. То есть в большинстве мест при добавлении локализации даже не придётся изменять код — он просто будет продолжать работать со строками, как и раньше!
Первый вариант локализации очень простой и подходит для игр, где совсем мало строк, и все они расположены в UI. Мы просто добавляем каждому объекту с переводимым компонентом UnityEngine.UI.Text
вот такой компонент:
public class TranslatableUIText : MonoBehaviour
{
public TranslatableString translatableString;
public void Start()
{
GetComponent().text = translatableString;
}
}
Заполняем все строки переводов в инспекторе — и вуаля, готово!
Для игр, где лексем больше, я использую другой подход. У меня есть Singleton
-объект LexemeLibrary
, который хранит в себе карту вида «id лексемы» => «сериализованный TranslatableString
», из которой я и получаю лексемы в нужных мне местах. Заполнять эту библиотеку можно любым удобным способом: ручками в инспекторе, через кастомный интерфейс (привет, Editor GUI) или путём экспорта/импорта CSV-файлов. Последний вариант прекрасно работает с аутсорс-переводчиками, но требует немного больше труда для избежания ошибок.
Кстати, полезная вещь — язык системы игрока (по сути, его локализационные предпочтения) можно получить с помощью, например, вот такого кода:
void SetLanguage(int language_id)
{
PlayerPrefs.SetInt("language_id", language_id);
}
public void GuessLanguage()
{
switch (Application.systemLanguage) {
case SystemLanguage.English:
SetLanguage(TranslatableString.LANG_EN);
return;
case SystemLanguage.Russian:
SetLanguage(TranslatableString.LANG_RU);
return;
case SystemLanguage.German:
SetLanguage(TranslatableString.LANG_DE);
return;
}
}
Умный термин — Dependency inversion principle.
Пишите подробные логи!
Это может показаться излишним, но теперь некоторые мои игры пишут в лог практически каждый чих. С одной стороны, это дико захламляет консоль Unity (которая, к сожалению, не умеет заниматься никакой удобной фильтрацией), с другой — вы можете открыть в любом удобном вам софте для просмотра логов исходные лог-файлы и составлять по ним любые удобные вам отчёты, которые помогут заниматься как оптимизацией приложения, так и поиском аномалий и их причин.
Создавайте самодостаточные сущности
Я делал глупости. Предположим, мы хотим как-то хранить настройки различных уровней какой-то игры:
public struct Mission
{
public int duration;
public float enemyDelay;
public float difficultyMultiplier;
}
public class MissionController : Singleton
{
public Mission[] missions;
public int currentMissionId;
}
Компонент MissionController
сидит в каком-нибудь объекте, содержит в себе настройки всех миссий игры и доступен из любого места кода через MissionController.Instance
.
Про мой класс Singleton можно почитать в уже упомянутой статье.
Мой первоначальный подход был такой: Mission
хранит в себе только параметры, а MissionController
занимается всеми прочими запросами. Например, чтобы получить лучший счёт игрока на определённом уровне я использовал методы вида
MissionController.GetHighScore(int missionId)
{
return PlayerPrefs.GetInt("MissionScore" + missionId);
}
Казалось бы, всё работает исправно. Но затем таких методов становилось всё больше, сущности разрастались, появлялись прокси-методы в других классах… В общем, наступил спагетти-ад. Поэтому в конечном счёте я решил вынести все методы для работы с миссиями в саму структуру Mission
и стал получать рекорды миссии, например, таким образом:
MissionController.GetCurrentMission().GetHighScore();
что сделало код гораздо более читабельным и удобноподдерживаемым.
Не бойтесь использовать PlayerPrefs
Во многих источниках говорится, что PlayerPrefs
нужно использовать очень осторожно и при каждом возможном случае вместо этого самостоятельно сериализовывать данные и писать их в собственные файлы. Раньше я старательно готовил свой формат бинарного файла для каждой сохраняемой сущности. Теперь я так не делаю.
Класс PlayerPrefs
занимается тем, что хранит пары «ключ => значение» в файловой системе, причём работает одинаково на всех платформах, просто хранит свои файлы в разных местах.
Постоянно писать данные в поля PlayerPrefs
(и читать их) — плохо: регулярные запросы к диску никому добра не делают. Однако можно написать простую, но разумную систему, которая поможет этого избежать.
Например, можно создать единый SAVE-объект, который хранит в себе все настройки и данные игрока:
[System.Serializable]
public struct Save
{
public string name;
public int exp;
public int[] highScores;
public int languageId;
public bool muteMusic;
}
Пишем простую систему, которая занимается ленивой инициализацией этого объекта (при первом запросе читает его из PlayerPrefs
, кладёт в переменную и при дальнейших запросах использует уже эту переменную), все изменения пишет в этот объект и сохраняет его обратно в PlayerPrefs
только при необходимости (например, при выходе из игры и изменении ключевых данных).
Для того чтобы манипулировать таким объектом как строкой для PlayerPrefs.GetString()
и PlayerPrefs.SetString()
, достаточно использовать сериализацию в JSON:
Save save = newSave;
string serialized = JsonUtility.ToJson(newSave);
Save unserialized = JsonUtility.FromJson(serialized);
Следите за объектами в сцене
Вот вы запустили свою игру. Она работает, вы радуетесь. Поиграли в неё минут 15, поставили на паузу, чтобы проверить этот любопытный ворнинг в консоли… ОБОЖЕМОЙ, ПОЧЕМУ У МЕНЯ В СЦЕНЕ 745 ОБЪЕКТОВ В КОРНЕ??? КАК МНЕ ЧТО-НИБУДЬ НАЙТИ???
Разбираться в этом мусоре очень сложно. Поэтому старайтесь придерживаться двух правил:
Кладите все создаваемые через Instantiate()
объекты в какие-нибудь объектные структуры. Например, у меня в сцене теперь всегда есть объект GameObjects
с подобъектами-категориями, в которые я кладу всё, что создаю. Во избежание человеческих ошибок в большинстве случаев у меня существуют надстройки над Instantiate()
вроде InstantiateDebris()
, которые сразу же кладут объект в нужную категорию.
Удаляйте объекты, которые больше не нужны. Например, у некоторых моих надстроек есть вызов Destroy(gameObject, timeout);
с заранее прописанным для каждой категории тайм-аутом. Благодаря этому мне не нужно париться об очистке таких вещей, как пятна крови на стенах, дырки от пуль, улетевшие в бесконечность снаряды…
Избегайте GameObject.Find ()
Очень дорогая с точки зрения ресурсов функция для поиска объектов. Да ещё и завязана она на имени объекта, которое нужно каждый раз изменять как минимум в двух местах (в сцене и в коде). То же можно сказать и про GameObject.FindWithTag()
(я бы вообще предложил отказаться от использования тегов — всегда можно найти более удобные способы определения типа объекта).
Если уж очень приспичит, обязательно кешируйте в переменную каждый вызов, чтобы не делать его больше одного раза. Или вообще сделайте связи объектов через инспектор.
Но можно делать и более изящно. Можно использовать класс — хранилище ссылок на объекты, в который регистрируется каждый потенциально нужный объект, сохранить в него мета-объект GameObjects
из предыдущего совета и искать нужные объекты в нём через transform.Find()
. Всё это гораздо лучше, чем опрашивать каждый объект в сцене о его имени в поисках необходимого, а потом всё равно упасть с ошибкой, потому что ты недавно этот объект переименовал.
Кстати, компонент Transform имплементирует интерфейс IEnumerable
, а значит, можно удобно обходить все дочерние объекты объекта таким образом:
foreach (Transform child in transform) {
child.gameObject.setActive(true);
}
Важно: в отличие от большинства других функций для поиска объектов, transform.Find () возвращает даже отключенные (gameObject.active == false) в данный момент объекты.
Договоритесь с художником о формате изображений
Особенно если художник — это вы сами. Особенно если художник никогда раньше не работал над играми и IT-проектами в целом.
Дать много советов по текстурам для 3D-игр я не смогу — сам ещё глубоко в это не закапывался. Важно научить художника сохранять все картинки с POT-габаритами (Power Of Two, чтобы каждая сторона картинки была степенью двойки, например, 512×512 или 1024×2048), чтобы они эффективнее сжимались движком и не занимали драгоценные мегабайты (что особенно важно для мобильных игр).
А вот рассказать грустных историй про спрайты для 2D-игр я могу много.
- Объединяйте однотипные спрайты (а тем более отдельные спрайты одной анимации) в общую картинку. Если вам нужно 12 спрайтов размером 256×256 пикселей, то не нужно сохранять 12 картинок — гораздо удобнее сделать одну картинку размером 1024×1024 пикселей, и в ней разложить спрайты по сетке со стороной в 256 пикселей и воспользоваться автоматической системой разбивания текстуры на спрайты. Останется четыре свободных места — не беда, вдруг понадобится добавить ещё картинок такого типа. Важно: если слотов под спрайты станет не хватать, то скажите своему художнику увеличивать полотно до новых степеней двойки только направо и вверх; в этом случае вам не придётся править мета-данные для уже имеющихся спрайтов — они останутся на тех же координатах.
- Обязательно рисуйте все спрайты проекта в одном масштабе, даже если они всё-таки оказываются на разных текстурах. Не представляете, сколько времени я потратил на подгон значений Pixels per unit для разных спрайтов монстров, чтобы в игровом мире они были соответствующих размеров. Сейчас на каждой текстуре у меня есть неиспользуемое изображение главного персонажа, чтобы можно было сравнивать соответствие масштабов. Ничего сложного —, а столько времени и нервов экономит!
- Выравнивайте все однотипные спрайты относительно одного общего Pivot«а. В идеале — центра картинки или середины какой-нибудь стороны. Например, все спрайты оружия игрока стоит располагать в слоте (или на отдельной картинке) так, чтобы точка, за которую игрок будет это оружие держать, была ровно в центре. Иначе придётся выставлять этот Pivot руками в редакторе; это будет неудобно, про это можно забыть — и персонаж будет держать копьё за самый кончик или топор за основание лезвия. Очень глупый персонаж.
Устанавливайте майлстоуны
Что это такое? По хорошему, майлстоун (milestones — камни, которые в былые времена устанавливали вдоль дороги каждую милю для отмечания расстояний) — это определённое состояние проекта, когда он достиг поставленных на данный момент целей и может переходить к дальнейшему развитию. А может и не переходить.
Наверное, это была наша главная ошибка при работе над дебютным проектом. Мы поставили перед собой очень много целей и шли ко всем сразу. Всегда что-то оставалось недоделанным, и мы никак не могли сказать: «А вот теперь проект действительно готов!», потому что к имеющемуся функционалу постоянно хотелось добавить что-то ещё.
Не надо так делать. Лучший способ развития игры — точно знать конечный набор фич и не отходить от него. Но это уж больно редко бывает, если речь идёт не о крупной индустриальной разработке. Игры часто развиваются и модернизируются прямо в процессе разработки. Так как же вовремя остановиться?
Составьте план версий (майлстоунов). Так, чтобы каждая версия была завершённой игрой: чтобы не было никаких временных заглушек, костылей и недореализованного функционала. Так, чтобы на любом майлстоуне было не стыдно сказать: «На этом мы и закончим!» и выпустить в свет (или навсегда закрыть в шкафу) качественный продукт.
Заключение
Глупый я был три года назад, да? Надеюсь, вы не будете повторять мои ошибки и сэкономите много времени и нервов. А если вы боялись даже попробовать начать заниматься разработкой игр, может быть, я смог вас хоть немного на это мотивировать.
P.S. Я подумываю о написании туториала вида «Делаем игрушку для хакатона за сутки с нуля», по которому человек без знания Unity и навыков программирования смог бы написать свою первую игру. На русском языке качественных туториалов такого формата не очень много. Как думаете, стоит попробовать?