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

    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 и как работать с пост-процессингом на примере того, что мы сегодня обсуждали.

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 11

      +2
      хочу предложить вам вместо этого велосипеда, использовать Addressables. Решает поставленную задачу из коробки )
        0
        Мы точно говорим об одном и том же? :)
          +1
          да, у адрессейбл есть AssetReference, который может быть сценой в том числе, и есть метод загрузки сцены. Таким образом нет привязки к имени.
            0
            Можете привести пример именно для сцен? А как с параметризацией сцен там обстоят дела?
              +2
              public AssetReference scene;
              
                      void LoadScene()
                      {
                          scene.LoadSceneAsync(LoadSceneMode.Additive);
                      }
        –6

        Щас бы статьи Ссыкутина читать

          0

          Так и не увидел решения проблемы:


          Например, при переименовании сцены всё полетит, а выяснится это только в самом конце на этапе выполнения.

          Способ, описанный в статье, просто меняет шило на мыло.

            0
            Если переименовать сцену плагин также переименует класс после чего средства статического анализа предупредят об ошибке. В случае со строкой такого не произойдёт
            +1

            AssetDatabase API доступен только в редакторе, у вас разве код соберется вообще из примера?

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

          Самое читаемое