Ошибки новичка Unity, испытанные на собственной шкуре

в 15:16, , рубрики: C#, unity, unity3d, Блог компании Badoo, геймдев, игры, компьютерные игры, Программирование, разработка игр
Ошибки новичка Unity, испытанные на собственной шкуре - 1

Привет. Это снова я, Илья Кудинов, QA-инженер из компании Badoo. Но сегодня я расскажу не о тестировании (этим я уже занимался в понедельник), а о геймдеве. Нет, этим мы в Badoo не занимаемся, разрабатывать компьютерные игры — моё хобби.

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

Я мечтал разрабатывать игрушки с детства. Наверное, уже в далёком 1994 году, когда мне подарили мою первую Dendy, я думал: “Как была бы здолава, если бы вот в этай иглушке было бы ещё всякое классное...” В средней школе я начал учиться программировать и вместе с товарищем делал свои первые играбельные поделки (ох, как мы их любили!). В институте мы с друзьями строили наполеоновские планы о кардинальном изменении индустрии с помощью нашей совершенно новой темы…

А в 2014 году я начал изучать Unity и наконец-то НА САМОМ ДЕЛЕ начал делать игры. Однако вот беда: я никогда не работал программистом. У меня не было опыта настоящей корпоративной разработки (до этого я всё делал “на коленке”, и, кроме меня, в моём коде никто бы не разобрался). Я умел программировать, но я не умел делать это хорошо. Все мои знания Unity и C# ограничивались скудными ещё на тот момент официальными туториалами. А мой любимый способ познавать мир — делать ошибки и учиться на них. И я наделал их предостаточно.

Сегодня я расскажу о некоторых из них и покажу, как их избежать (ах, если бы я знал всё это три года назад!)

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

Не засовывайте всю логику объекта в один MonoBehaviour

Ах, мой класс MonsterBehaviour в нашей дебютной игре! 3200 строк спагетти-кода в его худшие дни. Каждая необходимость вернуться к этому классу вызывала у меня лёгкую дрожь, и я всегда старался отложить эту работу так надолго, как только мог. Когда спустя чуть больше года после его создания я-таки добрался до его рефакторинга, я не только разбил его на базовый класс и несколько наследников, но и вынес несколько блоков функционала в отдельные классы, которые добавлял в объекты прямо из кода с помощью gameObject.AddComponent(), поэтому мне не пришлось изменять уже накопившиеся префабы.

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

Стало:

  • абстрактный класс MonsterComponent, от которого наследуются все прочие компоненты и который занимается их связыванием и, к примеру, базовой оптимизацией в виде кеширования результатов вызова gameObject.GetComponent<T>();
  • класс MonsterStats, в который геймдизайнер заносит параметры монстров. Он их хранит, изменяет с уровнем и отдаёт другим классам по запросу;
  • класс MonsterPathFinder, который занимается поиском путей и хранит в статических полях сгенерированные данные для оптимизации алгоритма;
  • абстрактный класс MonsterAttack с наследниками под разные виды атаки (оружием, когтями, магией...), которые контролируют всё, что касается боевого поведения монстра — тайминги, анимацию, применение особых приёмов;
  • ещё много дополнительных классов, реализующих всяческую специфическую логику.

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

Что, суть моего совета в том, чтобы не писать гигантские классы, спасибо, Кэп? Нет. Мой совет: дробите вашу логику на атомарные классы ещё до того, как они станут большими. Пусть сначала ваши объекты будут иметь три-четыре осмысленных компонента по десятку строк в коде каждого, но ориентироваться в них будет не сложнее, чем в одном из 50 строк, зато при дальнейшем развитии логики вы не окажетесь в такой ситуации, как я. Заодно появляется больше возможностей для переиспользования кода — например, компонент, отвечающий за здоровье и получение урона, можно прилепить и игроку, и противникам, и даже препятствиям.

Умный термин — Interface segregation principle.

Не забывайте про ООП

Каким бы простым ни казалось на первый взгляд проектирование объектов в Unity (“Программирование мышкой, фуууу”), не нужно недооценивать эту составляющую разработки. Да-да, я вот недооценивал. Прямо по пунктам:

  • Наследование. Всегда приятно вынести какую-то общую логику нескольких классов в общий базовый класс. Иногда это имеет смысл сделать заранее, если объекты “идеологически” похожи, пусть и не имеют пока общих методов. Например, сундуки на уровне и декоративные факелы на стенах поначалу не имели ничего общего. Но когда мы начали разрабатывать механику тушения и зажигания факелов, пришлось выносить из сундуков в общий класс механику взаимодействия с ними игрока и показ подсказок в интерфейсе. А мог бы и сразу догадаться. А ещё у меня есть общий базовый класс для всех объектов, являющийся надстройкой над MonoBehaviour, с кучкой полезных новых функций.
  • Инкапсуляция. Даже не буду объяснять, насколько полезной может быть установка правильных областей видимости. Упрощает работу, снижает вероятность глупой ошибки, позволяет удобнее дебажиться… Здесь ещё полезно знать про две директивы — [HideInInspector], скрывающую в инспекторе публичные поля компонента, которые не стоит править в объектах (впрочем, имеет смысл по возможности вообще избегать публичных полей, это плохая практика), и [SerializeField], напротив, отображающую в инспекторе приватные поля (что бывает очень полезно для более удобного дебага).
  • Полиморфизм. Здесь вопрос исключительно в красоте и лаконичности кода. Одна из моих любимых штук для поддержки полиморфизма в C# — универсальные шаблоны. Например, я написал такие простые и удобные методы для выдёргивания случайного элемента произвольного класса из List<T> (а делаю я это очень часто):

protected T GetRandomFromList<T>(List<T> list)
{
	return list[Random.Range(0, list.Count)];
}

protected T PullRandomFromList<T>(ref List<T> list)
{
	int i = Random.Range(0, list.Count);
	T result = list[i];
	list.RemoveAt(i);
	return result;
}

При этом C# — такая душка, что позволяет не плодить эти параметры, и вот эти два вызова будут работать идентично:

List<ExampleClass> list = new List<ExampleClass>();
ExampleClass a = GetRandomFromList<ExampleClass>(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<UnityEngine.UI.Text>().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<MissionController> 
{
	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<Save>(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 и навыков программирования смог бы написать свою первую игру. На русском языке качественных туториалов такого формата не очень много. Как думаете, стоит попробовать?

Автор: Кудинов Илья

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js