Делаем работу нашего гейм-дизайнера в Unity удобнее и приятнее
Сразу скажу, что наша команда все еще активно учится и развивается, хоть мы уже и выпустили дебютную игру. И если «дедлайны не горят», то я предпочитаю разбираться в каких-то вещах сам, а не обращаться к экспертам и различным best practices. Поэтому что-то из рассказанного мною может оказаться не оптимальным или банальным. Буду очень рад, если в таких случаях вы подскажете мне более удобные решения в комментариях и личных сообщениях. Ну и в целом информация здесь скорее базового уровня.
Код для Unity я пишу исключительно на C#, поэтому все выкладки в статье будут именно на этом языке.
Singleton-объекты
В архитектуре любой игры зачастую предусмотрены различные классы менеджеров, фабрик и хелперов, которым не нужны физические представления в игровом мире. В идеальном случае можно было бы реализовать их классами со статическими методами, не создавать в сцене никаких GameObject для их работы и спокойно пользоваться кодом вида GameController.MakeEverybodyHappy (). Однако у этого подхода есть два существенных минуса в нашем случае:
- для изменения каких-либо параметров гейм-дизайнерам придется лазить напрямую в код, а они это очень не любят;
- будет сложнее использовать ссылки на любые ассеты в Unity (префабы, текстуры и т.д.), так как придется загружать их через Resources.load (), а такой код поддерживать существенно труднее, чем те ссылки, которые можно создавать через интерфейс Unity.
Решение проблемы? Наследовать ваши классы от MonoBehaviour и создавать для каждого из них объект в сцене. Минусы этого подхода? При обращении к этим объектам придется пользоваться извращенными вызовами типа FindObjectOfType
Значит, нам нужен какой-то механизм, который позволит получать интересующий нас объект без какой-либо магии и контролировать, что на момент каждого обращения в нашей сцене будет один и ровно один объект этого класса.
Наше решение выглядит следующим образом («костяк» класса я когда-то давно нашел на просторах интернета и слегка доработал для собственного удобства):
using UnityEngine;
public class Singleton : MonoBehaviour where T : MonoBehaviour
{
private static T _instance;
public void Awake()
{
// Если в сцене уже есть объект с таким компонентом, то
// он пропишет себя в _instance при инициализации
if (!_instance) {
_instance = gameObject.GetComponent();
} else {
Debug.LogError("[Singleton] Second instance of '" + typeof (T) + "' created!");
}
}
public static T Instance
{
get
{
if (_instance == null) {
_instance = (T) FindObjectOfType(typeof(T));
if (FindObjectsOfType(typeof(T)).Length > 1) {
Debug.LogError("[Singleton] multiple instances of '" + typeof (T) + "' found!");
}
if (_instance == null) {
// Если в сцене объектов с этим классом нет - создаём
// новый GameObject и лепим ему наш компонент
GameObject singleton = new GameObject();
_instance = singleton.AddComponent();
singleton.name = "(singleton) " + typeof(T).ToString();
DontDestroyOnLoad(singleton);
Debug.Log("[Singleton] An instance of '" + typeof(T) + "' was created: " + singleton);
} else {
Debug.Log("[Singleton] Using instance of '" + typeof(T) + "': " + _instance.gameObject.name);
}
}
return _instance;
}
}
}
Как этим пользоваться? Наследуем свои классы от Singleton, указав самого себя в шаблоне:
public class GameController : Singleton
В дальнейшем мы сможем обращаться к полям и методам нашего класса как GameController.Instance.MakeEverybodyHappy () в любом месте кода. Чтобы создавать ссылки на ассеты, достаточно добавить этот компонент на любой объект в сцене и настраивать его привычным образом (и сохранять в префаб для верности). Мы используем объект Library в корне сцены для хранения всех Singleton-классов, настройка которых может понадобиться нашему гейм-дизайнеру, чтоб ему не приходилось их искать по всей сцене. Конечно, эта реализация никоим образом не имплементирует методологию Singleton, и называется мой класс так только потому, что он позволяет в общих чертах поддерживать архитектуру, создаваемую в рамках этой методологии.
Альтернативой является написание собственных панелей инспектора, которые позволят хранить данные и даже ссылки на ассеты в настоящих статических классах, но предложенный вариант проще и ничуть не менее удобен.
Кастомные поля инспектора класса
Итак, гейм-дизайнер получил свои визуальные интерфейсы и начал творить геймплей. И достаточно быстро начал ненавидеть меня, невинного разработчика, за все неудобства, которые на него наваливаются. Массив сериализованных объектов? Многомерные массивы? Почему это все настолько неудобно настраивать? Как бы вы ни старались сделать универсальную и расширяемую систему на стороне кода, ваш гейм-дизайнер предпочел бы видеть минимальное количество выпадающих списков и уж тем более массивов элементов с названиями вроде Element 73. Но разве мы можем что-то с этим поделать?
На самом деле можем. Предположим, в нашей игре появляются настройки сложности, и в данный момент вариантов три. Но, возможно, станет больше. Поэтому мы смотрим в будущее и для упрощения дальнейшего ВОЗМОЖНОГО увеличения количества сложностей создаем вот такой замечательный класс «зависящих-от-сложности-интов» и заменяем в нужных местах обычные инты на него:
[System.Serializable]
public class DifDepInt
{
public int[] values = {0, 0, 0};
static public implicit operator int (DifDepInt val)
{
return val.Get();
}
public int Get()
{
return values[GameConfig.Instance.difficulty];
}
}
DifDep означает Difficulty Dependant. Конечно, с точки зрения архитектуры лучше было бы сделать вместо этого специфического класса шаблон DifDep
Итак, мы довольны собой, мы получили возможность без особых трудов ввести в игру варьирующиеся параметры. Но наш гейм-дизайнер, которому надо это все настраивать, почему-то недоволен. Надо бы спросить его, что происходит… Ах вот оно что!
Да, однозначно, это не очень интуитивно и удобно. Давайте сделаем так, чтобы все выглядело иначе! Для этого мы воспользуемся вышеназванной «плюшкой» Unity — возможностью создавать кастомных инспекторов для отображения различных классов. Это достаточно хитрая система, позволяющая вам делать практически все что угодно, но в ней не так просто разобраться с первого взгляда (с самого начала она меня отпугнула, и поэтому какое-то время мы-таки страдали со стандартным инспектором, но в конце концов момент истины настал).
Итак, мы пишем следующий код:
#if UNITY_EDITOR
using UnityEditor;
[CustomPropertyDrawer(typeof(DifDepInt))]
public class DifDepIntDrawer : PropertyDrawer
{
int difCount = 3;
public override void OnGUI (Rect position, SerializedProperty property, GUIContent label)
{
EditorGUI.BeginProperty(position, label, property);
Rect contentPosition = EditorGUI.PrefixLabel(position, label);
contentPosition.width *= 1 / difCount;
float width = contentPosition.width;
SerializedProperty values = property.FindPropertyRelative ("values");
for (int i = 0; i < difCount; i++) {
EditorGUI.PropertyField (contentPosition, values.GetArrayElementAtIndex(i), GUIContent.none);
contentPosition.x += width;
}
EditorGUI.EndProperty();
}
}
#endif
Давайте разберемся, что тут происходит. Директива компилятора #if UNITY_EDITOR сообщает Unity, что она должна компилировать этот класс только во время разработки в редакторе. В противном случае она будет пытаться собрать этот код при сборке билда игры, а модуль UnityEditor там недоступен целиком, и это может вызвать сбивающие с толку ошибки.
[CustomPropertyDrawer (typeof (DifDepInt))] говорит Unity, что для отрисовки полей классов типа DifDepInt ей нужно использовать предоставленный ниже код вместо стандартного. Таких директив можно указать сколько угодно подряд для всех DifDep-классов, которые вам понадобятся — сам код кастомного редактора написан так, что примет любые классы, имеющие в себе массив элементов под названием values, поэтому этот класс у меня обслуживает и int, и float, и даже Sprite и GameObject.
Мы перегружаем метод OnGUI (), который и занимается отрисовкой области редактирования поля в инспекторе. Unity вызывает его иногда несколько раз за кадр — это нужно иметь в виду. Не забываем оставлять методы EditorGUI.BeginProperty () и EditorGUI.EndProperty (), без них корректно работать ваш код не будет.
Остальной код достаточно интуитивно понятен, если заглянуть в документацию Unity. Вместо магии с contentPosition можно использовать методы отрисовки из класса EditorGUILayout, а не EditorGUI, однако они не всегда ведут себя очевидным образом и в некоторых плохих случаях разбираться с ними себе дороже.
Ради чего же мы этим занимались? Смотрите, какая красота!
Это однозначно удобнее того, что было. Возможности, которые дает подобный функционал, практически безграничны — вы можете отображать самые сложные структуры максимально удобным для редактирования способом. Но не думайте, что гейм-дизайнер будет вам благодарен. Он примет это как должное, я вам гарантирую (:
Кастомные редакторы целого класса
Окей, красиво рисовать отдельные поля мы научились, а можем ли мы рисовать что-то, что охватывает весь класс? Конечно же да! Например, параметры всех грейдов отдельно взятого вида оружия мы задаем вот так:
Помимо редактирования полей здесь присутствует еще и калькулятор, значения в котором изменяются автоматически при изменении параметров оружия (на самом деле они read-only, вид инпутов они имеют только для консистентности и удобства выравнивания).
Как же сделать что-то подобное? Очень просто и схоже с тем, что мы делали до этого! Продемонстрирую на простом примере— добавлении простенького калькулятора DPS перед всеми остальными полями в классе поведения монстра:
#if UNITY_EDITOR
using UnityEditor;
[CustomEditor(typeof(EnemyBehaviour), true)]
public class EnemyCalculatorDrawer : Editor
{
public override void OnInspectorGUI() {
EnemyBehaviour enemy = (EnemyBehaviour)target;
float dps1, dps20;
dps1 = enemy.damageLeveling.Get(1) / enemy.getAttackDelay(1);
dps20 = enemy.damageLeveling.Get(20) / enemy.getAttackDelay(20);
GUIStyle myStyle = new GUIStyle ();
myStyle.richText = true;
myStyle.padding.left = 50;
EditorGUILayout.LabelField("Calculator", myStyle);
EditorGUILayout.LabelField("DPS on level 1: " + dps1.ToString("0.00"), myStyle);
EditorGUILayout.LabelField("DPS on level 20: " + dps20.ToString("0.00"), myStyle);
EditorGUILayout.Separator();
base.OnInspectorGUI();
}
}
#endif
Ситуация очень похожая: сначала мы сообщаем Unity о желании заменить отрисовщик для этого класса с помощью директивы [CustomEditor (typeof (EnemyBehaviour), true)]. Затем переопределяем метод OnInspectorGUI () (да, в этот раз не OnGUI (), потому что разработчик должен страдать), пишем в нем свою кастомную логику (унаследованное от класса Editor поле под названием target содержит в себе ссылку на отображаемый объект как на Object) и затем вызываем base.OnInspectorGUI (), чтобы Unity отрисовал все остальные поля так же, как и обычно. GUIStyle позволяет нам изменять внешний вид отображаемых данных. В этом случае я использовал методы из EditorGUILayout просто потому, что здесь совершенно не надо было беспокоиться о выравненном позиционировании.
Итог же выглядит так:
Соответственно, таким образом можно отрисовывать в инспекторе все что угодно, хоть графики зависимости урона и выживаемости от уровня и этапа игры.
Всякие мелочи
Конечно, можно делать огромное количество других вещей, чтобы спасти глаза и мозг ваших коллег. Unity предлагает целый набор директив для того, чтобы превратить простыню public-полей в структурированное целое. Самая важная из них, это, конечно, [HideInInspector], которая позволяет скрыть public-поле из инспектора. И больше не нужно вопить: «Пожалуйста, не трогайте эти галочки, они служебные!», и затем все равно часами разбираться, почему все монстры внезапно начали ходить задом наперед. Помимо этого есть еще приятные вещи вроде [Header («Stats»)], которые позволяют отображать аккуратный заголовок перед блоком полей, и [Space], который просто делает небольшой отступ между полями, помогая разбивать их на смысловые группы. Все три эти директивы нужно писать непосредственно перед объявлением public-поля (если вы поставите [Header ()] перед приватным полем, то ругаться Unity не станет, но никакого заголовка не отобразит).
Небольшая подсказка: если ваш сериализуемый объект имеет в себе string-поле под названием name, то когда вы засовываете несколько таких объектов в публичный массив, его «имя» будет отображаться вместо неинтуитивного Element X в инспекторе.
И ещё один полезный совет: даже если какой-то настраиваемый объект лежит у вас в сцене и является единственным представителем своего рода, все равно имеет смысл сделать из него префаб. Тогда, в случае совместной работы над проектом, не произойдут конфликты из-за одновременного редактирования сцены: правки, внесённые в инстанс префаба и применённые с помощью кнопки Apply, никак не аффектят файл сцены.
Любой проект, над которым трудится более одного человека, заставляет своих участников чем-то жертвовать ради других, особенно когда область деятельности каждого из них очень сильно отличается. Разработка компьютерных игр — дело для специалистов из множества разных областей. В небольшой сплоченной команде долг каждого стараться все делать для того, чтобы минимизировать суммарные жертвы. А значит, потраченные пара часов для изучения кастомных редакторов или еще каких-либо приемов, которые кажутся не очень важными с точки зрения разработки кода — замечательное вливание в ваш проект, которое способно спасти не один час работы и миллионы нервных клеток ваших коллег. Товарищи разработчики-программисты, давайте жить дружно с теми, для кого ваш код все равно что поэмы на традиционном китайском языке для вас.
Товарищи разработчики-программисты, в совершенстве владеющие традиционным китайским языком, — глубокий поклон вам и извинения за такие предрассудки.