Как мы переосмыслили работу со сценами в Unity

в 10:52, , рубрики: C#, csharp, Gamedev, unity, unity3d, разработка игр

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

Сейчас я вам расскажу о том, как мы написали плагин для Unity на основе пост-процессинга проектов и кодогенератора CodeDom.

Проблема

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

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

Решение

При добавлении сцены в проект, генерируется одноимённый класс с методом Load.

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

Menu.Load();

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

//------------------------------------------------------------------------------
// <auto-generated>
//     This code was generated by a tool.
//     Runtime Version:4.0.30319.42000
//
//     Changes to this file may cause incorrect behavior and will be lost if
//     the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------

namespace IJunior.TypedScenes
{   
    public class Menu : TypedScene
    {
        private const string GUID = "a3ac3ba38209c7744b9e05301cbfa453";
        
        public static void Load()
        {
            LoadScene(GUID);
        }
    }
}

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

namespace IJunior.TypedScenes
{
    public abstract class TypedScene
    {
        protected static void LoadScene(string guid)
        {
            var path = AssetDatabase.GUIDToAssetPath(guid);
            SceneManager.LoadScene(path);
        }

        protected static void LoadScene<T>(string guid, T argument)
        {
            var path = AssetDatabase.GUIDToAssetPath(guid);

            UnityAction<Scene, Scene> handler = null;
            handler = (from, to) =>
            {
                if (to.name == Path.GetFileNameWithoutExtension(path))
                {
                    SceneManager.activeSceneChanged -= handler;
                    HandleSceneLoaders(argument);
                }
            };

            SceneManager.activeSceneChanged += handler;
            SceneManager.LoadScene(path);
        }

        private static void HandleSceneLoaders<T>(T loadingModel)
        {
            foreach (var rootObjects in SceneManager.GetActiveScene().GetRootGameObjects())
            {
                foreach (var handler in rootObjects.GetComponentsInChildren<ISceneLoadHandler<T>>())
                {
                    handler.OnSceneLoaded(loadingModel);
                }
            }
        }
    }
}

В этой реализации видна ещё одна фишка - передача параметров сценам.

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

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

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

В таком случае мы можем сами создать такой компонент.

using IJunior.TypedScenes;
using System.Collections.Generic;
using UnityEngine;

public class GameLoadHandler : MonoBehaviour, ISceneLoadHandler<IEnumerable<Player>>
{
    public void OnSceneLoaded(IEnumerable<Player> players)
    {
        foreach (var player in players)
        {
            //make avatars
        }
    }
}

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

//------------------------------------------------------------------------------
// <auto-generated>
//     This code was generated by a tool.
//     Runtime Version:4.0.30319.42000
//
//     Changes to this file may cause incorrect behavior and will be lost if
//     the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------

namespace IJunior.TypedScenes
{
    public class Game : TypedScene
    {
        private const string GUID = "976661b7057d74e41abb6eb799024ada";
        
        public static void Load(System.Collections.Generic.IEnumerable<Player> argument)
        {
            LoadScene(GUID, argument);
        }
    }
}

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

Это не фишка, а скорее недоработка, так как такой функционал быстрее создаст путаницу, нежели будет полезен.

А почему не сделать через N?

Первую версию плагина я осветил на своём YouTube канале в этом видео.

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

Чем плох статический класс с полями, через которые передаются данные для сцены?

Нередко встречаю и такое. Речь идёт о классе по типу этого:

public class GameArguments
{
    public IEnumerable<Player> Players { get; set; }
}

А уже внутри сцены, вероятно, будет группа компонентов, которая достаёт данные из свойств.

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

Ну и опять же сцену придётся запускать по ID или имени.

Чем плох PlayerPerfs

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

Параллель с ASPNet

Мне хотелось получить что-то схожее с строго типизированными View из ASPNet Core. Мы считаем плохим тоном использовать ViewData и стараемся определять ViewModel. В Unity хочется что-то такого же толка с теми же преимуществами.

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

Но такой подход не очень распространён, к сожалению, и на это, я думаю, есть свои причины.

Где скачать

Плагин мы сделали в паре с Владиславом Койдо в рамках Proof-of-concept. Он ещё не стабилен и не обкатан как следует, но с ним уже можно поиграться.

Репозиторий на GitHub - https://github.com/HolyMonkey/unity-typed-scenes

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

Автор: Роман Сакутин

Источник

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


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