[Перевод] Трюки со скриптами в редакторе Unity, которые сэкономят вам кучу времени и нервов. Часть 2

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

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

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

Начнем с распаковки элементов. При настройке элементов игры мы часто сталкиваемся со следующим сценарием:

С одной стороны, у нас есть префабы (Prefabs), полученные от команды художников — будь то префаб, сгенерированный импортером FBX, или префаб, для которого были тщательно настроены все соответствующие материалы и анимации, добавлены пропы в иерархию и т. д. Чтобы использовать этот префаб в игре, имеет смысл создать на его основе Prefab Variant и добавить туда все компоненты, связанные с геймплеем. Таким образом, команда художников может изменять и обновлять префаб, и все изменения сразу же отражаются в игре. Хоть это и вполне рабочий подход, когда для предмета требуется всего пара компонентов с простыми настройками, он может добавить много работы, если вам нужно каждый раз настраивать что-то сложное с нуля.

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

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

Трюк №7: Настраивайте компоненты с самого начала

Чаще всего при рассмотрении сложных игровых элементов я сталкиваюсь со следующей структурой: есть «главный» компонент (например, «враг», «пикап» или «дверь»), который служит интерфейсом для взаимодействия с объектом, и ряд небольших многократно используемых компонентов, реализующих само поведение: такие вещи, как «selectable», «CharacterMovement» или «UnitHealth», а также встроенные компоненты Unity, например рендеры и коллайдеры.

Некоторым компонентам для работы могут быть необходимы другие компоненты. Например, для движения персонажа может потребоваться агент NavMesh. Именно поэтому в Unity есть атрибут RequireComponent, который позволяет определить все эти зависимости. Так, если для данного типа объекта существует «главный» компонент, вы можете использовать атрибут RequireComponent, чтобы добавить все компоненты, которые должны быть у этого типа объекта.

Например, юниты в моем прототипе имеют следующие атрибуты:

[AddComponentMenu("My Units/Soldier")]
[RequireComponent(typeof(Locomotion))]
[RequireComponent(typeof(AttackComponent))]
[RequireComponent(typeof(Vision))]
[RequireComponent(typeof(Armor))]
public class Soldier : Unit
{
…
}
cs

Помимо установки местоположения в AddComponentMenu, добавьте все необходимые дополнительные компоненты. В данном случае я добавил Locomotion для передвижения и AttackComponent для атаки других юнитов.

Кроме того, базовый класс Unit (он общий для самих юнитов и зданий) имеет другие атрибуты RequireComponent, которые наследуются этим классом, например, компонент Health. Таким образом, мне нужно добавить в игровой объект только компонент Soldier, а все остальные компоненты добавятся автоматически. Если я добавлю к компоненту новый атрибут RequireComponent, Unity дополнит все существующие GameObject’ы новым компонентом, что значительно облегчает расширение существующих объектов.

У RequireComponent есть и более тонкое преимущество: если у нас есть «компонент A», который требует «компонент B», то добавление A к GameObject не просто гарантирует, что B тоже будет добавлен — оно гарантирует, что B будет добавлен раньше A. Это означает, что когда будет вызван метод Reset для компонента A, компонент B уже будет существовать, и мы легко получим к нему доступ. Это позволит нам устанавливать ссылки на компоненты, регистрировать постоянные события UnityEvents и делать все остальное, что необходимо для настройки объекта. Комбинируя атрибут RequireComponent и метод Reset, мы можем полностью настроить объект, добавив всего один компонент.

Трюк №8: Совместное использование данных в несвязанных префабах

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

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

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

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

Создайте пресет из исходного компонента и примените его к другому компоненту (компонентам) следующим образом:

private static void ApplyTemplate(GameObject go, GameObject template)
{
    // Получаем все компоненты объекта

    foreach (var comp in go.GetComponents())
    {
        // Пытаемся получить соответствующий компонент в шаблоне

        if (!template.TryGetComponent(comp.GetType(), out var templateComp))
            continue;

        // Создаем пресет

        var preset = new Preset(templateComp);
        // Применяем его

        preset.ApplyTo(comp);
    }
}

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

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

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

private static void ApplyTemplate(GameObject go, GameObject template)
{
    // Получаем все компоненты объекта
    foreach (var comp in go.GetComponents())
    {
        // Пытаемся получить соответствующий компонент в шаблоне
        if (!template.TryGetComponent(comp.GetType(), out var templateComp))
            continue;

        // Получаем список модификаций
        var overrides = new List();
        var changes = PrefabUtility.GetPropertyModifications(templateComp);
        if (changes == null || changes.Length == 0)
            continue;

        // Отбираем те, которые подходят нашему компоненту 
        var target = PrefabUtility.GetCorrespondingObjectFromSource(templateComp);
        foreach (var change in changes)
        {
            if (change.target == target)
                overrides.Add(change.propertyPath);
        }

        // Создаем пресет
        var preset = new Preset(templateComp);
        // Применяем соответствующие переопределения
        if (overrides.Count > 0)
            preset.ApplyTo(comp, overrides.ToArray());
    }
}

Этот код применит все переопределения шаблона к нашим префабам. Остается только одна деталь: шаблон сам может быть вариантом варианта, и нам может потребоваться применить переопределения и этого варианта.

Для этого нам нужно лишь сделать код рекурсивным:

private static void ApplyTemplateRecursive(GameObject go, GameObject template)
{
    // Если мы имеем дело с вариантом, то переходим к базовому префабу

    var templateSource = PrefabUtility.GetCorrespondingObjectFromSource(template);
    if (templateSource)
        ApplyTemplateRecursive(go, templateSource);

    // Применяем переопределения из текущего префаба
    ApplyTemplate(go, template);
}

Далее давайте найдем шаблон для наших префабов. В идеале мы хотим использовать разные шаблоны для разных типов объектов. Один из эффективных способов сделать это — поместить шаблоны в ту же папку, что и объекты, к которым мы хотим их применить.

Ищите объект с именем Template.prefab в той же папке, что и наш Prefab. Если мы его не найдем, то будем рекурсивно искать в родительской папке:

private void OnPostprocessPrefab(GameObject gameObject)
{
    // Рекурсивный вызов для применения шаблона
    SetupAllComponents(gameObject, Path.GetDirectoryName(assetPath), context);
}

private static void SetupAllComponents(GameObject go, string folder, AssetImportContext context = null)
{
    // Следим, чтобы не применять шаблоны к шаблонам!

    if (go.name == "Template" || go.name.Contains("Base"))
        return;

    // Если дошли до корневого каталога, возвращаемся 
    if (string.IsNullOrEmpty(folder))
        return;

    // Добавляем путь в качестве зависимости, чтобы он был повторно импортирован при изменении префаба
    var templatePath = string.Join("/", folder, "Template.prefab");
    if (context != null)
        context.DependsOnArtifact(templatePath);

    // Если файл не существует, то проверяем родительский каталог 
    if (!File.Exists(templatePath))
    {
        SetupAllComponents(go, Path.GetDirectoryName(folder), context);
        return;
    }

    // Применяем шаблон
    var template = AssetDatabase.LoadAssetAtPath(templatePath);
    if (template)
        ApplyTemplateSourceRecursive(go, template);
}

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

Трюк №9: Балансировка игровых данных с помощью ScriptableObjects и электронных таблиц

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

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

Вот тут-то и приходят на помощь электронные таблицы. Их можно экспортировать в простые форматы, такие как CSV (.csv) или TSV (.tsv), и это именно то, для чего нужны ScriptedImporters. Ниже приведен скриншот со статами юнитов в нашем прототипе:

Example of a spreadsheet | Tech from the Trenches

Пример электронной таблицы

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

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

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

[ScriptedImporter(0, "tsv")]
public class UnitStatsImporter : ScriptedImporter
{
    public override void OnImportAsset(AssetImportContext ctx)
    {
        var file = File.OpenText(assetPath);

        // Определение юнит это или постройка
        bool isUnit = !assetPath.Contains("Buildings");

        // Первая строка — заголовок, игнорируем его

        file.ReadLine();

        var main = new TextAsset();
        ctx.AddObjectToAsset("Main", main);
        ctx.SetMainObject(main);

        while (!file.EndOfStream)
        {
            // Считываем построчно
            var line = file.ReadLine();
            var lineElements = line.Split('\t');

            var name = lineElements[0].ToLower();
            if (isUnit)
            {
                // Заполняем все значения

                var entry = ScriptableObject.CreateInstance();
                entry.name = name;
                entry.HP = float.Parse(lineElements[1]);
                entry.attack = float.Parse(lineElements[2]);
                entry.defense = float.Parse(lineElements[3]);
                entry.attackRatio = float.Parse(lineElements[4]);
                entry.attackRange = float.Parse(lineElements[5]);
                entry.viewRange = float.Parse(lineElements[6]);
                entry.speed = float.Parse(lineElements[7]);

                ctx.AddObjectToAsset(name, entry);
            }
            else
            {
                // Заполняем все значения

                var entry = ScriptableObject.CreateInstance();
                entry.name = name;
                entry.HP = float.Parse(lineElements[1]);

                ctx.AddObjectToAsset(name, entry);
            }
        }

        // Закрываем файл

        file.Close();
    }
}

Теперь у нас есть несколько ScriptableObjects, которые содержат все данные из электронной таблицы.

Импортированные данные из электронной таблицы

Импортированные данные из электронной таблицы

Сгенерированные ScriptableObjects готовы к использованию в игре. Вы также можете использовать PrefabPostprocessor, который был настроен ранее.

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

Трюк №10: Ускорение итерации в редакторе

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

Одна из первых вещей, которая приходит в голову когда речь заходит о времени итерации в Unity, — это Domain Reload. Domain Reload актуальна в двух ключевых ситуациях: после компиляции кода для загрузки новых динамически подключаемых библиотек (DLL), а также при входе и выходе из Play Mode. Перезагрузки, возникающей при компиляции, избежать невозможно, но у вас есть возможность отключить перезагрузку, связанную с игровым режимом, в меню Project Settings > Editor > Enter Play Mode Settings.

Отключение перезагрузки при входе в Play Mode может вызвать определенные проблемы, если ваш код на это не рассчитан. Наиболее распространенной проблемой является то, что статические переменные не сбрасываются после игры. Если ваш код может работать, когда это отключено, то смело вырубайте это. В данном прототипе перезагрузка отключена, поэтому вы можете войти в игровой режим практически мгновенно.

Трюк №11: Автогенерация данных

Отдельная проблема с временем итерации связана с повторным вычислением данных, необходимых для игры. Для того, чтобы запустить повторное вычисление, часто приходится вручную выбирать компоненты и самому нажимать на кнопки. Например, в этом прототипе для каждой команды (team) на сцене есть контроллер TeamController. Этот контроллер содержит список всех вражеских построек, чтобы можно было отправить юнитов атаковать них. Чтобы заполнить эти данные автоматически, используйте интерфейс IProcessSceneWithReport. Этот интерфейс вызывается для сцен в двух разных случаях: во время сборки и при загрузке сцены в Play Mode. С его помощью можно создавать, уничтожать и изменять любые объекты. Заметьте, однако, что эти изменения будут влиять только на сборки и Play Mode.

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

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

private List FindAllComponentsInScene (Scene scene) where T : Component
{
    var result = new List();
    var roots = scene.GetRootGameObjects();
    foreach (var root in roots)
    {
        result.AddRange(root.GetComponentsInChildren());
    }
    return result;
}

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

public void OnProcessScene(Scene scene, BuildReport report)
{
    // Находим все цели

    var targets = FindAllComponentsInScene(scene);

    if (targets.Count == 0)
        return;

    // Получаем команды, которым принадлежат постройки
    var allTeams = new List();
    foreach (var target in targets)
    {
        if (!allTeams.Contains(target.team))
            allTeams.Add(target.team);
    }

    // Создаем контроллеры команд
    foreach (var team in allTeams)
    {
        var obj = new GameObject(team.name + " Team", typeof(TeamController));
        var controller = obj.GetComponent();
        controller.team = team;

        foreach (var target in targets)
        {
            if (target.team != team)
                controller.allTargets.Add(target);
        }
    }
}

Трюк №12: Работа с несколькими сценами

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

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

Помните, что при переходе в Play Mode некоторые сцены могут быть открыты, поэтому прежде чем начать загружать сцену важно проверить, не загружена ли она уже:

private void Awake()
{
    foreach (var scene in m_scenes)
    {
        if (!SceneManager.GetSceneByBuildIndex(scene.idx).IsValid())
        {
            SceneManager.LoadScene(scene.idx, LoadSceneMode.Additive);
        }
    }
}

Заключение

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

Ассеты, использованные для создания прототипа, можно бесплатно найти в Asset Store:

Материал подготовлен в рамках практического онлайн-курса «Unity Game Developer. Professional».

Habrahabr.ru прочитано 13925 раз