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